refactor: deduplicate & other code quality fixes
This commit is contained in:
@@ -0,0 +1,63 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../models/plex_library.dart';
|
||||||
|
import '../models/plex_metadata.dart';
|
||||||
|
|
||||||
|
/// Mixin providing common state management for library tab screens
|
||||||
|
/// Standardizes loading, error handling, and lifecycle management
|
||||||
|
mixin LibraryTabStateMixin<T extends StatefulWidget> on State<T> {
|
||||||
|
/// The list of items to display
|
||||||
|
List<PlexMetadata> get items;
|
||||||
|
set items(List<PlexMetadata> value);
|
||||||
|
|
||||||
|
/// Whether data is currently loading
|
||||||
|
bool get isLoading;
|
||||||
|
set isLoading(bool value);
|
||||||
|
|
||||||
|
/// Error message if loading failed
|
||||||
|
String? get errorMessage;
|
||||||
|
set errorMessage(String? value);
|
||||||
|
|
||||||
|
/// The library being displayed
|
||||||
|
PlexLibrary get library;
|
||||||
|
|
||||||
|
/// Load or reload the content
|
||||||
|
Future<void> loadContent();
|
||||||
|
|
||||||
|
/// Common lifecycle: reload if library changed
|
||||||
|
@mustCallSuper
|
||||||
|
void didUpdateLibrary(PlexLibrary oldLibrary) {
|
||||||
|
if (oldLibrary.key != library.key) {
|
||||||
|
loadContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to set loading state
|
||||||
|
void setLoadingState(bool loading) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = loading;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to set error state
|
||||||
|
void setErrorState(String? error) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
errorMessage = error;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to set success state with items
|
||||||
|
void setSuccessState(List<PlexMetadata> newItems) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
items = newItems;
|
||||||
|
isLoading = false;
|
||||||
|
errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,9 +23,10 @@ class PlexHub {
|
|||||||
factory PlexHub.fromJson(Map<String, dynamic> json) {
|
factory PlexHub.fromJson(Map<String, dynamic> json) {
|
||||||
final metadataList = <PlexMetadata>[];
|
final metadataList = <PlexMetadata>[];
|
||||||
|
|
||||||
// Hubs can contain either Metadata or Directory entries
|
// Helper function to parse entries from a JSON list
|
||||||
if (json['Metadata'] != null) {
|
void parseEntries(List? entries) {
|
||||||
for (final item in json['Metadata'] as List) {
|
if (entries == null) return;
|
||||||
|
for (final item in entries) {
|
||||||
try {
|
try {
|
||||||
metadataList.add(PlexMetadata.fromJson(item));
|
metadataList.add(PlexMetadata.fromJson(item));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -34,15 +35,9 @@ class PlexHub {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json['Directory'] != null) {
|
// Hubs can contain either Metadata or Directory entries
|
||||||
for (final item in json['Directory'] as List) {
|
parseEntries(json['Metadata'] as List?);
|
||||||
try {
|
parseEntries(json['Directory'] as List?);
|
||||||
metadataList.add(PlexMetadata.fromJson(item));
|
|
||||||
} catch (e) {
|
|
||||||
// Skip items that fail to parse
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return PlexHub(
|
return PlexHub(
|
||||||
hubKey: json['key'] as String? ?? '',
|
hubKey: json['key'] as String? ?? '',
|
||||||
|
|||||||
@@ -12,13 +12,40 @@ class PlexMediaInfo {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlexAudioTrack {
|
/// Mixin for building track labels with a consistent pattern
|
||||||
|
mixin TrackLabelBuilder {
|
||||||
|
int get id;
|
||||||
|
int? get index;
|
||||||
|
String? get displayTitle;
|
||||||
|
String? get language;
|
||||||
|
|
||||||
|
/// Builds a label from the given parts
|
||||||
|
/// If displayTitle is present, returns it
|
||||||
|
/// Otherwise, combines language and additional parts
|
||||||
|
String buildLabel(List<String> additionalParts) {
|
||||||
|
if (displayTitle != null && displayTitle!.isNotEmpty) {
|
||||||
|
return displayTitle!;
|
||||||
|
}
|
||||||
|
final parts = <String>[];
|
||||||
|
if (language != null && language!.isNotEmpty) {
|
||||||
|
parts.add(language!);
|
||||||
|
}
|
||||||
|
parts.addAll(additionalParts);
|
||||||
|
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlexAudioTrack with TrackLabelBuilder {
|
||||||
|
@override
|
||||||
final int id;
|
final int id;
|
||||||
|
@override
|
||||||
final int? index;
|
final int? index;
|
||||||
final String? codec;
|
final String? codec;
|
||||||
|
@override
|
||||||
final String? language;
|
final String? language;
|
||||||
final String? languageCode;
|
final String? languageCode;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
@override
|
||||||
final String? displayTitle;
|
final String? displayTitle;
|
||||||
final int? channels;
|
final int? channels;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
@@ -36,22 +63,24 @@ class PlexAudioTrack {
|
|||||||
});
|
});
|
||||||
|
|
||||||
String get label {
|
String get label {
|
||||||
if (displayTitle != null) return displayTitle!;
|
final additionalParts = <String>[];
|
||||||
final parts = <String>[];
|
if (codec != null) additionalParts.add(codec!.toUpperCase());
|
||||||
if (language != null) parts.add(language!);
|
if (channels != null) additionalParts.add('${channels!}ch');
|
||||||
if (codec != null) parts.add(codec!.toUpperCase());
|
return buildLabel(additionalParts);
|
||||||
if (channels != null) parts.add('${channels!}ch');
|
|
||||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class PlexSubtitleTrack {
|
class PlexSubtitleTrack with TrackLabelBuilder {
|
||||||
|
@override
|
||||||
final int id;
|
final int id;
|
||||||
|
@override
|
||||||
final int? index;
|
final int? index;
|
||||||
final String? codec;
|
final String? codec;
|
||||||
|
@override
|
||||||
final String? language;
|
final String? language;
|
||||||
final String? languageCode;
|
final String? languageCode;
|
||||||
final String? title;
|
final String? title;
|
||||||
|
@override
|
||||||
final String? displayTitle;
|
final String? displayTitle;
|
||||||
final bool selected;
|
final bool selected;
|
||||||
final bool forced;
|
final bool forced;
|
||||||
@@ -71,11 +100,9 @@ class PlexSubtitleTrack {
|
|||||||
});
|
});
|
||||||
|
|
||||||
String get label {
|
String get label {
|
||||||
if (displayTitle != null) return displayTitle!;
|
final additionalParts = <String>[];
|
||||||
final parts = <String>[];
|
if (forced) additionalParts.add('Forced');
|
||||||
if (language != null) parts.add(language!);
|
return buildLabel(additionalParts);
|
||||||
if (forced) parts.add('Forced');
|
|
||||||
return parts.isEmpty ? 'Track ${index ?? id}' : parts.join(' · ');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if this subtitle track is an external file (sidecar subtitle)
|
/// Returns true if this subtitle track is an external file (sidecar subtitle)
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../client/plex_client.dart';
|
||||||
|
import '../models/plex_metadata.dart';
|
||||||
|
import '../utils/provider_extensions.dart';
|
||||||
|
import '../utils/collection_playlist_play_helper.dart';
|
||||||
|
import '../mixins/refreshable.dart';
|
||||||
|
import '../mixins/item_updatable.dart';
|
||||||
|
|
||||||
|
/// Abstract base class for screens displaying media lists (collections/playlists)
|
||||||
|
/// Provides common state management and playback functionality
|
||||||
|
abstract class BaseMediaListDetailScreen<T extends StatefulWidget>
|
||||||
|
extends State<T> with Refreshable, ItemUpdatable {
|
||||||
|
// State properties - concrete implementations to avoid duplication
|
||||||
|
List<PlexMetadata> _items = [];
|
||||||
|
bool _isLoading = false;
|
||||||
|
String? _errorMessage;
|
||||||
|
|
||||||
|
List<PlexMetadata> get items => _items;
|
||||||
|
set items(List<PlexMetadata> value) => _items = value;
|
||||||
|
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
set isLoading(bool value) => _isLoading = value;
|
||||||
|
|
||||||
|
String? get errorMessage => _errorMessage;
|
||||||
|
set errorMessage(String? value) => _errorMessage = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
PlexClient get client => context.clientSafe;
|
||||||
|
|
||||||
|
/// The media item being displayed (collection or playlist)
|
||||||
|
dynamic get mediaItem;
|
||||||
|
|
||||||
|
/// Title to display in app bar
|
||||||
|
String get title;
|
||||||
|
|
||||||
|
/// Message to show when list is empty
|
||||||
|
String get emptyMessage;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load or reload the items (subclasses implement this)
|
||||||
|
Future<void> loadItems();
|
||||||
|
|
||||||
|
/// Play all items in the list
|
||||||
|
Future<void> playItems() => _playWithShuffle(false);
|
||||||
|
|
||||||
|
/// Shuffle play all items in the list
|
||||||
|
Future<void> shufflePlayItems() => _playWithShuffle(true);
|
||||||
|
|
||||||
|
/// Internal helper to play items with optional shuffle
|
||||||
|
Future<void> _playWithShuffle(bool shuffle) async {
|
||||||
|
if (items.isEmpty) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(emptyMessage)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final clientProvider = context.plexClient;
|
||||||
|
final client = clientProvider.client;
|
||||||
|
if (client == null) return;
|
||||||
|
|
||||||
|
await playCollectionOrPlaylist(
|
||||||
|
context: context,
|
||||||
|
client: client,
|
||||||
|
item: mediaItem,
|
||||||
|
shuffle: shuffle,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
final index = items.indexWhere((item) => item.ratingKey == ratingKey);
|
||||||
|
if (index != -1) {
|
||||||
|
items[index] = updatedMetadata;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void refresh() {
|
||||||
|
loadItems();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../client/plex_client.dart';
|
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../utils/provider_extensions.dart';
|
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/collection_playlist_play_helper.dart';
|
|
||||||
import '../widgets/media_card.dart';
|
import '../widgets/media_card.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../mixins/refreshable.dart';
|
|
||||||
import '../mixins/item_updatable.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../utils/grid_size_calculator.dart';
|
import '../utils/grid_size_calculator.dart';
|
||||||
|
import '../utils/dialogs.dart';
|
||||||
|
import '../utils/provider_extensions.dart';
|
||||||
|
import 'base_media_list_detail_screen.dart';
|
||||||
|
|
||||||
/// Screen to display the contents of a collection
|
/// Screen to display the contents of a collection
|
||||||
class CollectionDetailScreen extends StatefulWidget {
|
class CollectionDetailScreen extends StatefulWidget {
|
||||||
@@ -24,112 +21,49 @@ class CollectionDetailScreen extends StatefulWidget {
|
|||||||
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
|
State<CollectionDetailScreen> createState() => _CollectionDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
class _CollectionDetailScreenState
|
||||||
with Refreshable, ItemUpdatable {
|
extends BaseMediaListDetailScreen<CollectionDetailScreen> {
|
||||||
@override
|
@override
|
||||||
PlexClient get client => context.clientSafe;
|
PlexMetadata get mediaItem => widget.collection;
|
||||||
|
|
||||||
List<PlexMetadata> _items = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
String? _errorMessage;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
String get title => widget.collection.title;
|
||||||
super.initState();
|
|
||||||
_loadCollectionItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadCollectionItems() async {
|
@override
|
||||||
setState(() {
|
String get emptyMessage => t.collections.empty;
|
||||||
_isLoading = true;
|
|
||||||
_errorMessage = null;
|
@override
|
||||||
});
|
Future<void> loadItems() async {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final client = this.client;
|
||||||
final client = clientProvider.client;
|
final newItems = await client.getCollectionItems(widget.collection.ratingKey);
|
||||||
if (client == null) {
|
|
||||||
throw Exception(t.errors.noClientAvailable);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
items = newItems;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = await client.getCollectionItems(widget.collection.ratingKey);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items = items;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Loaded ${items.length} items for collection: ${widget.collection.title}',
|
'Loaded ${newItems.length} items for collection: ${widget.collection.title}',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to load collection items', error: e);
|
appLogger.e('Failed to load collection items', error: e);
|
||||||
setState(() {
|
|
||||||
_errorMessage = t.collections.failedToLoadItems(
|
|
||||||
error: e.toString(),
|
|
||||||
);
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void refresh() {
|
|
||||||
_loadCollectionItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
||||||
setState(() {
|
|
||||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
|
||||||
if (index != -1) {
|
|
||||||
_items[index] = updatedMetadata;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _playCollection() async {
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
setState(() {
|
||||||
SnackBar(content: Text(t.collections.empty)),
|
errorMessage = t.collections.failedToLoadItems(error: e.toString());
|
||||||
);
|
isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
|
||||||
context: context,
|
|
||||||
client: client,
|
|
||||||
item: widget.collection,
|
|
||||||
shuffle: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _shufflePlayCollection() async {
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(content: Text(t.collections.empty)),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
|
||||||
context: context,
|
|
||||||
client: client,
|
|
||||||
item: widget.collection,
|
|
||||||
shuffle: true,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteCollection() async {
|
Future<void> _deleteCollection() async {
|
||||||
@@ -137,8 +71,8 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
int? sectionId = widget.collection.librarySectionID;
|
int? sectionId = widget.collection.librarySectionID;
|
||||||
|
|
||||||
// If collection doesn't have it, try to get it from loaded items
|
// If collection doesn't have it, try to get it from loaded items
|
||||||
if (sectionId == null && _items.isNotEmpty) {
|
if (sectionId == null && items.isNotEmpty) {
|
||||||
sectionId = _items.first.librarySectionID;
|
sectionId = items.first.librarySectionID;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (sectionId == null) {
|
if (sectionId == null) {
|
||||||
@@ -151,28 +85,14 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Show confirmation dialog
|
// Show confirmation dialog
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDeleteConfirmation(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => AlertDialog(
|
title: t.collections.deleteCollection,
|
||||||
title: Text(t.collections.deleteCollection),
|
message: t.collections.deleteConfirm(title: widget.collection.title),
|
||||||
content: Text(
|
|
||||||
t.collections.deleteConfirm(title: widget.collection.title),
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
||||||
child: Text(t.common.delete),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed != true) return;
|
if (confirmed != true) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final clientProvider = context.plexClient;
|
||||||
@@ -184,6 +104,8 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
widget.collection.ratingKey,
|
widget.collection.ratingKey,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
if (success) {
|
if (success) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
@@ -221,18 +143,18 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
actions: [
|
actions: [
|
||||||
// Play button
|
// Play button
|
||||||
if (_items.isNotEmpty)
|
if (items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
tooltip: t.discover.play,
|
tooltip: t.discover.play,
|
||||||
onPressed: _playCollection,
|
onPressed: playItems,
|
||||||
),
|
),
|
||||||
// Shuffle button
|
// Shuffle button
|
||||||
if (_items.isNotEmpty)
|
if (items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.shuffle),
|
icon: const Icon(Icons.shuffle),
|
||||||
tooltip: t.common.shuffle,
|
tooltip: t.common.shuffle,
|
||||||
onPressed: _shufflePlayCollection,
|
onPressed: shufflePlayItems,
|
||||||
),
|
),
|
||||||
// Delete button
|
// Delete button
|
||||||
IconButton(
|
IconButton(
|
||||||
@@ -243,7 +165,7 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_errorMessage != null)
|
if (errorMessage != null)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -255,21 +177,21 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_errorMessage!),
|
Text(errorMessage!),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _loadCollectionItems,
|
onPressed: loadItems,
|
||||||
child: Text(t.common.retry),
|
child: Text(t.common.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_items.isEmpty && _isLoading)
|
else if (items.isEmpty && isLoading)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
else if (_items.isEmpty)
|
else if (items.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(t.collections.noItems),
|
child: Text(t.collections.noItems),
|
||||||
@@ -292,16 +214,16 @@ class _CollectionDetailScreenState extends State<CollectionDetailScreen>
|
|||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate(
|
delegate: SliverChildBuilderDelegate(
|
||||||
(context, index) {
|
(context, index) {
|
||||||
final item = _items[index];
|
final item = items[index];
|
||||||
return MediaCard(
|
return MediaCard(
|
||||||
key: Key(item.ratingKey),
|
key: Key(item.ratingKey),
|
||||||
item: item,
|
item: item,
|
||||||
onRefresh: updateItem,
|
onRefresh: updateItem,
|
||||||
collectionId: widget.collection.ratingKey,
|
collectionId: widget.collection.ratingKey,
|
||||||
onListRefresh: _loadCollectionItems,
|
onListRefresh: loadItems,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _items.length,
|
childCount: items.length,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,24 +4,18 @@ import 'package:dio/dio.dart';
|
|||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_library.dart';
|
import '../models/plex_library.dart';
|
||||||
import '../models/plex_metadata.dart';
|
import '../models/plex_metadata.dart';
|
||||||
import '../models/plex_filter.dart';
|
|
||||||
import '../models/plex_sort.dart';
|
import '../models/plex_sort.dart';
|
||||||
import '../providers/plex_client_provider.dart';
|
|
||||||
import '../providers/settings_provider.dart';
|
|
||||||
import '../providers/hidden_libraries_provider.dart';
|
import '../providers/hidden_libraries_provider.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../widgets/media_card.dart';
|
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../widgets/app_bar_back_button.dart';
|
|
||||||
import '../widgets/context_menu_wrapper.dart';
|
import '../widgets/context_menu_wrapper.dart';
|
||||||
import '../widgets/filters_bottom_sheet.dart';
|
|
||||||
import '../widgets/sort_bottom_sheet.dart';
|
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
import '../mixins/refreshable.dart';
|
import '../mixins/refreshable.dart';
|
||||||
import '../mixins/item_updatable.dart';
|
import '../mixins/item_updatable.dart';
|
||||||
import '../theme/theme_helper.dart';
|
import '../theme/theme_helper.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../utils/error_message_utils.dart';
|
||||||
import 'library_tabs/library_browse_tab.dart';
|
import 'library_tabs/library_browse_tab.dart';
|
||||||
import 'library_tabs/library_recommended_tab.dart';
|
import 'library_tabs/library_recommended_tab.dart';
|
||||||
import 'library_tabs/library_collections_tab.dart';
|
import 'library_tabs/library_collections_tab.dart';
|
||||||
@@ -53,14 +47,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
String? _selectedLibraryKey;
|
String? _selectedLibraryKey;
|
||||||
bool _isInitialLoad = true;
|
bool _isInitialLoad = true;
|
||||||
|
|
||||||
// Legacy fields kept for compatibility with existing methods
|
|
||||||
// TODO: Clean up after refactoring _loadLibraryContent
|
|
||||||
List<PlexFilter> _filters = [];
|
|
||||||
List<PlexSort> _sortOptions = [];
|
|
||||||
Map<String, String> _selectedFilters = {};
|
Map<String, String> _selectedFilters = {};
|
||||||
PlexSort? _selectedSort;
|
PlexSort? _selectedSort;
|
||||||
bool _isSortDescending = false;
|
bool _isSortDescending = false;
|
||||||
bool _isLoadingItems = false;
|
|
||||||
List<PlexMetadata> _items = [];
|
List<PlexMetadata> _items = [];
|
||||||
int _currentPage = 0;
|
int _currentPage = 0;
|
||||||
bool _hasMoreItems = true;
|
bool _hasMoreItems = true;
|
||||||
@@ -95,28 +84,18 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _updateState(VoidCallback fn) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(fn);
|
||||||
|
}
|
||||||
|
|
||||||
/// Helper method to get user-friendly error message from exception
|
/// Helper method to get user-friendly error message from exception
|
||||||
String _getErrorMessage(dynamic error, String context) {
|
String _getErrorMessage(dynamic error, String context) {
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
// Other Dio errors
|
return mapDioErrorToMessage(error, context: context);
|
||||||
switch (error.type) {
|
|
||||||
case DioExceptionType.connectionTimeout:
|
|
||||||
case DioExceptionType.receiveTimeout:
|
|
||||||
return t.errors.connectionTimeout(context: context);
|
|
||||||
case DioExceptionType.connectionError:
|
|
||||||
return t.errors.connectionFailed;
|
|
||||||
default:
|
|
||||||
appLogger.e('Error loading $context', error: error);
|
|
||||||
return t.errors.failedToLoad(
|
|
||||||
context: context,
|
|
||||||
error: error.message ?? 'Unknown error',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic error
|
return mapUnexpectedErrorToMessage(error, context: context);
|
||||||
appLogger.e('Unexpected error in $context', error: error);
|
|
||||||
return t.errors.failedToLoad(context: context, error: error.toString());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadLibraries() async {
|
Future<void> _loadLibraries() async {
|
||||||
@@ -154,7 +133,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
savedOrder,
|
savedOrder,
|
||||||
);
|
);
|
||||||
|
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_allLibraries =
|
_allLibraries =
|
||||||
orderedLibraries; // Store all libraries with ordering applied
|
orderedLibraries; // Store all libraries with ordering applied
|
||||||
_isLoadingLibraries = false;
|
_isLoadingLibraries = false;
|
||||||
@@ -187,7 +166,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
libraryKeyToLoad = visibleLibraries.first.key;
|
libraryKeyToLoad = visibleLibraries.first.key;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (libraryKeyToLoad != null) {
|
if (libraryKeyToLoad != null && mounted) {
|
||||||
final savedFilters =
|
final savedFilters =
|
||||||
storage.getLibraryFilters(sectionId: libraryKeyToLoad);
|
storage.getLibraryFilters(sectionId: libraryKeyToLoad);
|
||||||
if (savedFilters.isNotEmpty) {
|
if (savedFilters.isNotEmpty) {
|
||||||
@@ -197,7 +176,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_errorMessage = _getErrorMessage(e, 'libraries');
|
_errorMessage = _getErrorMessage(e, 'libraries');
|
||||||
_isLoadingLibraries = false;
|
_isLoadingLibraries = false;
|
||||||
});
|
});
|
||||||
@@ -267,16 +246,14 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
final clientProvider = context.plexClient;
|
final clientProvider = context.plexClient;
|
||||||
final client = clientProvider.client;
|
final client = clientProvider.client;
|
||||||
if (client == null) {
|
if (client == null) {
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_errorMessage = t.errors.noClientAvailable;
|
_errorMessage = t.errors.noClientAvailable;
|
||||||
_isLoadingItems = false;
|
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_selectedLibraryKey = libraryKey;
|
_selectedLibraryKey = libraryKey;
|
||||||
_isLoadingItems = true;
|
|
||||||
_errorMessage = null;
|
_errorMessage = null;
|
||||||
// Only clear filters when explicitly changing library (not on initial load)
|
// Only clear filters when explicitly changing library (not on initial load)
|
||||||
if (isChangingLibrary) {
|
if (isChangingLibrary) {
|
||||||
@@ -296,7 +273,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
// Restore saved tab index for this library
|
// Restore saved tab index for this library
|
||||||
final savedTabIndex = storage.getLibraryTab(libraryKey);
|
final savedTabIndex = storage.getLibraryTab(libraryKey);
|
||||||
if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) {
|
if (savedTabIndex != null && savedTabIndex >= 0 && savedTabIndex < 4) {
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_tabController.index = savedTabIndex;
|
_tabController.index = savedTabIndex;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -315,24 +292,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
final currentRequestId = ++_requestId;
|
final currentRequestId = ++_requestId;
|
||||||
|
|
||||||
// Reset pagination state
|
// Reset pagination state
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_currentPage = 0;
|
_currentPage = 0;
|
||||||
_hasMoreItems = true;
|
_hasMoreItems = true;
|
||||||
_items = [];
|
_items = [];
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Load filters and sort options for the new library
|
// Load sort options for the new library
|
||||||
_loadFilters(libraryKey);
|
|
||||||
await _loadSortOptions(libraryKey);
|
await _loadSortOptions(libraryKey);
|
||||||
|
|
||||||
// Add sort parameter to filters if selected
|
final filtersWithSort = _buildFiltersWithSort();
|
||||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
|
||||||
if (_selectedSort != null) {
|
|
||||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
|
||||||
descending: _isSortDescending,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load pages sequentially
|
// Load pages sequentially
|
||||||
await _loadAllPagesSequentially(
|
await _loadAllPagesSequentially(
|
||||||
@@ -347,9 +317,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_errorMessage = _getErrorMessage(e, 'library content');
|
_errorMessage = _getErrorMessage(e, 'library content');
|
||||||
_isLoadingItems = false;
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -376,15 +345,10 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
return; // Request was superseded
|
return; // Request was superseded
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_items.addAll(items);
|
_items.addAll(items);
|
||||||
_currentPage++;
|
_currentPage++;
|
||||||
_hasMoreItems = items.length >= _pageSize;
|
_hasMoreItems = items.length >= _pageSize;
|
||||||
|
|
||||||
// Mark as not loading if this is the last page
|
|
||||||
if (!_hasMoreItems) {
|
|
||||||
_isLoadingItems = false;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Check if it's a cancellation
|
// Check if it's a cancellation
|
||||||
@@ -393,8 +357,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
// For other errors, update state and rethrow
|
// For other errors, update state and rethrow
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_isLoadingItems = false;
|
|
||||||
_hasMoreItems = false;
|
_hasMoreItems = false;
|
||||||
});
|
});
|
||||||
rethrow;
|
rethrow;
|
||||||
@@ -402,26 +365,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadFilters(String libraryKey) async {
|
|
||||||
try {
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) {
|
|
||||||
throw Exception(t.errors.noClientAvailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
final filters = await client.getLibraryFilters(libraryKey);
|
|
||||||
setState(() {
|
|
||||||
_filters = filters;
|
|
||||||
});
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.w('Failed to load filters', error: e);
|
|
||||||
setState(() {
|
|
||||||
_filters = [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadSortOptions(String libraryKey) async {
|
Future<void> _loadSortOptions(String libraryKey) async {
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final clientProvider = context.plexClient;
|
||||||
@@ -455,85 +398,26 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
savedSort = sortOptions.first;
|
savedSort = sortOptions.first;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_sortOptions = sortOptions;
|
|
||||||
_selectedSort = savedSort;
|
_selectedSort = savedSort;
|
||||||
_isSortDescending = descending;
|
_isSortDescending = descending;
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
_updateState(() {
|
||||||
_sortOptions = [];
|
|
||||||
_selectedSort = null;
|
_selectedSort = null;
|
||||||
_isSortDescending = false;
|
_isSortDescending = false;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _applyFilters() async {
|
Map<String, String> _buildFiltersWithSort() {
|
||||||
// Cancel any existing requests
|
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
||||||
_cancelToken?.cancel();
|
if (_selectedSort != null) {
|
||||||
_cancelToken = CancelToken();
|
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
||||||
final currentRequestId = ++_requestId;
|
descending: _isSortDescending,
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_isLoadingItems = true;
|
|
||||||
_errorMessage = null;
|
|
||||||
_currentPage = 0;
|
|
||||||
_hasMoreItems = true;
|
|
||||||
_items = [];
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) {
|
|
||||||
throw Exception(t.errors.noClientAvailable);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add sort parameter to filters if selected
|
|
||||||
final filtersWithSort = Map<String, String>.from(_selectedFilters);
|
|
||||||
if (_selectedSort != null) {
|
|
||||||
filtersWithSort['sort'] = _selectedSort!.getSortKey(
|
|
||||||
descending: _isSortDescending,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load pages sequentially
|
|
||||||
await _loadAllPagesSequentially(
|
|
||||||
_selectedLibraryKey!,
|
|
||||||
filtersWithSort,
|
|
||||||
currentRequestId,
|
|
||||||
client,
|
|
||||||
);
|
);
|
||||||
} catch (e) {
|
|
||||||
// Ignore cancellation errors
|
|
||||||
if (e is DioException && e.type == DioExceptionType.cancel) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_errorMessage = t.messages.errorLoading(error: e.toString());
|
|
||||||
_isLoadingItems = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
return filtersWithSort;
|
||||||
|
|
||||||
Future<void> _applySort(PlexSort sort, bool descending) async {
|
|
||||||
setState(() {
|
|
||||||
_selectedSort = sort;
|
|
||||||
_isSortDescending = descending;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save sort preference for this library
|
|
||||||
final storage = await StorageService.getInstance();
|
|
||||||
await storage.saveLibrarySort(
|
|
||||||
_selectedLibraryKey!,
|
|
||||||
sort.key,
|
|
||||||
descending: descending,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reload content with new sort
|
|
||||||
_applyFilters();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -554,27 +438,27 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
void _refreshCurrentTab() {
|
void _refreshCurrentTab() {
|
||||||
switch (_tabController.index) {
|
switch (_tabController.index) {
|
||||||
case 0: // Recommended tab
|
case 0: // Recommended tab
|
||||||
final state = _recommendedTabKey.currentState;
|
final refreshable = _recommendedTabKey.currentState;
|
||||||
if (state is Refreshable) {
|
if (refreshable is Refreshable) {
|
||||||
(state as Refreshable).refresh();
|
(refreshable as Refreshable).refresh();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 1: // Browse tab
|
case 1: // Browse tab
|
||||||
final state = _browseTabKey.currentState;
|
final refreshable = _browseTabKey.currentState;
|
||||||
if (state is Refreshable) {
|
if (refreshable is Refreshable) {
|
||||||
(state as Refreshable).refresh();
|
(refreshable as Refreshable).refresh();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 2: // Collections tab
|
case 2: // Collections tab
|
||||||
final state = _collectionsTabKey.currentState;
|
final refreshable = _collectionsTabKey.currentState;
|
||||||
if (state is Refreshable) {
|
if (refreshable is Refreshable) {
|
||||||
(state as Refreshable).refresh();
|
(refreshable as Refreshable).refresh();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 3: // Playlists tab
|
case 3: // Playlists tab
|
||||||
final state = _playlistsTabKey.currentState;
|
final refreshable = _playlistsTabKey.currentState;
|
||||||
if (state is Refreshable) {
|
if (refreshable is Refreshable) {
|
||||||
(state as Refreshable).refresh();
|
(refreshable as Refreshable).refresh();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -713,7 +597,13 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _scanLibrary(PlexLibrary library) async {
|
Future<void> _performLibraryAction({
|
||||||
|
required PlexLibrary library,
|
||||||
|
required Future<void> Function(PlexClient client) action,
|
||||||
|
required String progressMessage,
|
||||||
|
required String successMessage,
|
||||||
|
required String Function(Object error) failureMessage,
|
||||||
|
}) async {
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final clientProvider = context.plexClient;
|
||||||
final client = clientProvider.client;
|
final client = clientProvider.client;
|
||||||
@@ -721,32 +611,31 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
throw Exception(t.errors.noClientAvailable);
|
throw Exception(t.errors.noClientAvailable);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show progress indicator
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(t.messages.libraryScanning(title: library.title)),
|
content: Text(progressMessage),
|
||||||
duration: const Duration(seconds: 2),
|
duration: const Duration(seconds: 2),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await client.scanLibrary(library.key);
|
await action(client);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(t.messages.libraryScanStarted(title: library.title)),
|
content: Text(successMessage),
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to scan library', error: e);
|
appLogger.e('Library action failed', error: e);
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(t.messages.libraryScanFailed(error: e.toString())),
|
content: Text(failureMessage(e)),
|
||||||
backgroundColor: Colors.red,
|
backgroundColor: Colors.red,
|
||||||
duration: const Duration(seconds: 3),
|
duration: const Duration(seconds: 3),
|
||||||
),
|
),
|
||||||
@@ -755,134 +644,46 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _scanLibrary(PlexLibrary library) async {
|
||||||
|
return _performLibraryAction(
|
||||||
|
library: library,
|
||||||
|
action: (client) => client.scanLibrary(library.key),
|
||||||
|
progressMessage: t.messages.libraryScanning(title: library.title),
|
||||||
|
successMessage: t.messages.libraryScanStarted(title: library.title),
|
||||||
|
failureMessage: (error) =>
|
||||||
|
t.messages.libraryScanFailed(error: error.toString()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _refreshLibraryMetadata(PlexLibrary library) async {
|
Future<void> _refreshLibraryMetadata(PlexLibrary library) async {
|
||||||
try {
|
return _performLibraryAction(
|
||||||
final clientProvider = context.plexClient;
|
library: library,
|
||||||
final client = clientProvider.client;
|
action: (client) => client.refreshLibraryMetadata(library.key),
|
||||||
if (client == null) {
|
progressMessage: t.messages.metadataRefreshing(title: library.title),
|
||||||
throw Exception(t.errors.noClientAvailable);
|
successMessage: t.messages.metadataRefreshStarted(title: library.title),
|
||||||
}
|
failureMessage: (error) =>
|
||||||
|
t.messages.metadataRefreshFailed(error: error.toString()),
|
||||||
// Show progress indicator
|
);
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.messages.metadataRefreshing(title: library.title)),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.refreshLibraryMetadata(library.key);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
t.messages.metadataRefreshStarted(title: library.title),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.e('Failed to refresh library metadata', error: e);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
t.messages.metadataRefreshFailed(error: e.toString()),
|
|
||||||
),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _emptyLibraryTrash(PlexLibrary library) async {
|
Future<void> _emptyLibraryTrash(PlexLibrary library) async {
|
||||||
try {
|
return _performLibraryAction(
|
||||||
final clientProvider = context.plexClient;
|
library: library,
|
||||||
final client = clientProvider.client;
|
action: (client) => client.emptyLibraryTrash(library.key),
|
||||||
if (client == null) {
|
progressMessage: t.libraries.emptyingTrash(title: library.title),
|
||||||
throw Exception(t.errors.noClientAvailable);
|
successMessage: t.libraries.trashEmptied(title: library.title),
|
||||||
}
|
failureMessage: (error) => t.libraries.failedToEmptyTrash(error: error),
|
||||||
|
);
|
||||||
// Show progress indicator
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.emptyingTrash(title: library.title)),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.emptyLibraryTrash(library.key);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.trashEmptied(title: library.title)),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.e('Failed to empty library trash', error: e);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.failedToEmptyTrash(error: e)),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _analyzeLibrary(PlexLibrary library) async {
|
Future<void> _analyzeLibrary(PlexLibrary library) async {
|
||||||
try {
|
return _performLibraryAction(
|
||||||
final clientProvider = context.plexClient;
|
library: library,
|
||||||
final client = clientProvider.client;
|
action: (client) => client.analyzeLibrary(library.key),
|
||||||
if (client == null) {
|
progressMessage: t.libraries.analyzing(title: library.title),
|
||||||
throw Exception(t.errors.noClientAvailable);
|
successMessage: t.libraries.analysisStarted(title: library.title),
|
||||||
}
|
failureMessage: (error) => t.libraries.failedToAnalyze(error: error),
|
||||||
|
);
|
||||||
// Show progress indicator
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.analyzing(title: library.title)),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
await client.analyzeLibrary(library.key);
|
|
||||||
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.analysisStarted(title: library.title)),
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.e('Failed to analyze library', error: e);
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.libraries.failedToAnalyze(error: e)),
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
duration: const Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildTabChip(String label, int index) {
|
Widget _buildTabChip(String label, int index) {
|
||||||
|
|||||||
@@ -9,15 +9,14 @@ import '../../models/plex_sort.dart';
|
|||||||
import '../../providers/plex_client_provider.dart';
|
import '../../providers/plex_client_provider.dart';
|
||||||
import '../../providers/settings_provider.dart';
|
import '../../providers/settings_provider.dart';
|
||||||
import '../../utils/provider_extensions.dart';
|
import '../../utils/provider_extensions.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/error_message_utils.dart';
|
||||||
import '../../utils/grid_size_calculator.dart';
|
import '../../utils/grid_size_calculator.dart';
|
||||||
import '../../widgets/media_card.dart';
|
import '../../widgets/media_card.dart';
|
||||||
import '../../widgets/app_bar_back_button.dart';
|
|
||||||
import '../../widgets/folder_tree_view.dart';
|
import '../../widgets/folder_tree_view.dart';
|
||||||
import '../../widgets/filters_bottom_sheet.dart';
|
import '../../widgets/filters_bottom_sheet.dart';
|
||||||
import '../../widgets/sort_bottom_sheet.dart';
|
import '../../widgets/sort_bottom_sheet.dart';
|
||||||
import '../../services/storage_service.dart';
|
import '../../services/storage_service.dart';
|
||||||
import '../../services/settings_service.dart' show LibraryDensity, ViewMode;
|
import '../../services/settings_service.dart' show ViewMode;
|
||||||
import '../../mixins/item_updatable.dart';
|
import '../../mixins/item_updatable.dart';
|
||||||
import '../../mixins/refreshable.dart';
|
import '../../mixins/refreshable.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
@@ -301,24 +300,11 @@ class _LibraryBrowseTabState extends State<LibraryBrowseTab>
|
|||||||
|
|
||||||
String _getErrorMessage(dynamic error) {
|
String _getErrorMessage(dynamic error) {
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
switch (error.type) {
|
return mapDioErrorToMessage(error, context: t.libraries.content);
|
||||||
case DioExceptionType.connectionTimeout:
|
|
||||||
case DioExceptionType.receiveTimeout:
|
|
||||||
return t.errors.connectionTimeout(context: t.libraries.content);
|
|
||||||
case DioExceptionType.connectionError:
|
|
||||||
return t.errors.connectionFailed;
|
|
||||||
default:
|
|
||||||
appLogger.e('Error loading library content', error: error);
|
|
||||||
return t.errors.failedToLoad(
|
|
||||||
context: t.libraries.content,
|
|
||||||
error: error.message ?? t.common.unknown,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
appLogger.e('Unexpected error loading library content', error: error);
|
return mapUnexpectedErrorToMessage(
|
||||||
return t.errors.failedToLoad(
|
error,
|
||||||
context: t.libraries.content,
|
context: t.libraries.content,
|
||||||
error: error.toString(),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,14 +4,12 @@ import 'package:provider/provider.dart';
|
|||||||
import '../../models/plex_library.dart';
|
import '../../models/plex_library.dart';
|
||||||
import '../../models/plex_metadata.dart';
|
import '../../models/plex_metadata.dart';
|
||||||
import '../../providers/plex_client_provider.dart';
|
import '../../providers/plex_client_provider.dart';
|
||||||
import '../../providers/settings_provider.dart';
|
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/library_refresh_notifier.dart';
|
import '../../utils/library_refresh_notifier.dart';
|
||||||
import '../../widgets/media_card.dart';
|
|
||||||
import '../../services/settings_service.dart' show LibraryDensity, ViewMode;
|
|
||||||
import '../../utils/grid_size_calculator.dart';
|
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../mixins/refreshable.dart';
|
import '../../mixins/refreshable.dart';
|
||||||
|
import '../../widgets/content_state_builder.dart';
|
||||||
|
import '../../widgets/adaptive_media_grid.dart';
|
||||||
|
|
||||||
/// Collections tab for library screen
|
/// Collections tab for library screen
|
||||||
/// Shows collections for the current library
|
/// Shows collections for the current library
|
||||||
@@ -113,82 +111,19 @@ class _LibraryCollectionsTabState extends State<LibraryCollectionsTab>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
if (_isLoading && _collections.isEmpty) {
|
return ContentStateBuilder<PlexMetadata>(
|
||||||
return const Center(child: CircularProgressIndicator());
|
isLoading: _isLoading,
|
||||||
}
|
errorMessage: _errorMessage,
|
||||||
|
items: _collections,
|
||||||
if (_errorMessage != null && _collections.isEmpty) {
|
emptyIcon: Icons.collections,
|
||||||
return Center(
|
emptyMessage: t.libraries.noCollections,
|
||||||
child: Column(
|
onRetry: _loadCollections,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
builder: (items) => RefreshIndicator(
|
||||||
children: [
|
onRefresh: _loadCollections,
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
child: AdaptiveMediaGrid(
|
||||||
const SizedBox(height: 16),
|
items: items,
|
||||||
Text(_errorMessage!),
|
onRefresh: _loadCollections,
|
||||||
const SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _loadCollections,
|
|
||||||
child: Text(t.common.retry),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_collections.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.collections, size: 64, color: Colors.grey),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(t.libraries.noCollections),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _loadCollections,
|
|
||||||
child: Consumer<SettingsProvider>(
|
|
||||||
builder: (context, settingsProvider, child) {
|
|
||||||
if (settingsProvider.viewMode == ViewMode.list) {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
||||||
itemCount: _collections.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final collection = _collections[index];
|
|
||||||
return MediaCard(
|
|
||||||
key: Key(collection.ratingKey),
|
|
||||||
item: collection,
|
|
||||||
onListRefresh: _loadCollections,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return GridView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
||||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
|
||||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
|
||||||
context,
|
|
||||||
settingsProvider.libraryDensity,
|
|
||||||
),
|
|
||||||
childAspectRatio: 2 / 3.3,
|
|
||||||
crossAxisSpacing: 0,
|
|
||||||
mainAxisSpacing: 0,
|
|
||||||
),
|
|
||||||
itemCount: _collections.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final collection = _collections[index];
|
|
||||||
return MediaCard(
|
|
||||||
key: Key(collection.ratingKey),
|
|
||||||
item: collection,
|
|
||||||
onListRefresh: _loadCollections,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import '../../providers/plex_client_provider.dart';
|
|||||||
import '../../providers/settings_provider.dart';
|
import '../../providers/settings_provider.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/library_refresh_notifier.dart';
|
import '../../utils/library_refresh_notifier.dart';
|
||||||
import '../../services/settings_service.dart' show LibraryDensity, ViewMode;
|
import '../../services/settings_service.dart' show ViewMode;
|
||||||
import '../../utils/grid_size_calculator.dart';
|
import '../../utils/grid_size_calculator.dart';
|
||||||
import '../../widgets/media_card.dart';
|
import '../../widgets/media_card.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../mixins/refreshable.dart';
|
import '../../mixins/refreshable.dart';
|
||||||
|
import '../../widgets/content_state_builder.dart';
|
||||||
|
|
||||||
/// Playlists tab for library screen
|
/// Playlists tab for library screen
|
||||||
/// Shows playlists that contain items from the current library
|
/// Shows playlists that contain items from the current library
|
||||||
@@ -117,82 +118,55 @@ class _LibraryPlaylistsTabState extends State<LibraryPlaylistsTab>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
if (_isLoading && _playlists.isEmpty) {
|
return ContentStateBuilder<PlexPlaylist>(
|
||||||
return const Center(child: CircularProgressIndicator());
|
isLoading: _isLoading,
|
||||||
}
|
errorMessage: _errorMessage,
|
||||||
|
items: _playlists,
|
||||||
if (_errorMessage != null && _playlists.isEmpty) {
|
emptyIcon: Icons.playlist_play,
|
||||||
return Center(
|
emptyMessage: t.playlists.noPlaylists,
|
||||||
child: Column(
|
onRetry: _loadPlaylists,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
builder: (items) => RefreshIndicator(
|
||||||
children: [
|
onRefresh: _loadPlaylists,
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
child: Consumer<SettingsProvider>(
|
||||||
const SizedBox(height: 16),
|
builder: (context, settingsProvider, child) {
|
||||||
Text(_errorMessage!),
|
if (settingsProvider.viewMode == ViewMode.list) {
|
||||||
const SizedBox(height: 16),
|
return ListView.builder(
|
||||||
ElevatedButton(
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||||
onPressed: _loadPlaylists,
|
itemCount: items.length,
|
||||||
child: Text(t.common.retry),
|
itemBuilder: (context, index) {
|
||||||
),
|
final playlist = items[index];
|
||||||
],
|
return MediaCard(
|
||||||
),
|
key: Key(playlist.ratingKey),
|
||||||
);
|
item: playlist,
|
||||||
}
|
onListRefresh: _loadPlaylists,
|
||||||
|
);
|
||||||
if (_playlists.isEmpty) {
|
},
|
||||||
return Center(
|
);
|
||||||
child: Column(
|
} else {
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
return GridView.builder(
|
||||||
children: [
|
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||||
const Icon(Icons.playlist_play, size: 64, color: Colors.grey),
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
const SizedBox(height: 16),
|
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||||
Text(t.playlists.noPlaylists),
|
context,
|
||||||
],
|
settingsProvider.libraryDensity,
|
||||||
),
|
),
|
||||||
);
|
childAspectRatio: 2 / 3.3,
|
||||||
}
|
crossAxisSpacing: 0,
|
||||||
|
mainAxisSpacing: 0,
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _loadPlaylists,
|
|
||||||
child: Consumer<SettingsProvider>(
|
|
||||||
builder: (context, settingsProvider, child) {
|
|
||||||
if (settingsProvider.viewMode == ViewMode.list) {
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
||||||
itemCount: _playlists.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final playlist = _playlists[index];
|
|
||||||
return MediaCard(
|
|
||||||
key: Key(playlist.ratingKey),
|
|
||||||
item: playlist,
|
|
||||||
onListRefresh: _loadPlaylists,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return GridView.builder(
|
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
|
||||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
|
||||||
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
|
||||||
context,
|
|
||||||
settingsProvider.libraryDensity,
|
|
||||||
),
|
),
|
||||||
childAspectRatio: 2 / 3.3,
|
itemCount: items.length,
|
||||||
crossAxisSpacing: 0,
|
itemBuilder: (context, index) {
|
||||||
mainAxisSpacing: 0,
|
final playlist = items[index];
|
||||||
),
|
return MediaCard(
|
||||||
itemCount: _playlists.length,
|
key: Key(playlist.ratingKey),
|
||||||
itemBuilder: (context, index) {
|
item: playlist,
|
||||||
final playlist = _playlists[index];
|
onListRefresh: _loadPlaylists,
|
||||||
return MediaCard(
|
);
|
||||||
key: Key(playlist.ratingKey),
|
},
|
||||||
item: playlist,
|
);
|
||||||
onListRefresh: _loadPlaylists,
|
}
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../utils/app_logger.dart';
|
|||||||
import '../../widgets/hub_section.dart';
|
import '../../widgets/hub_section.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../mixins/refreshable.dart';
|
import '../../mixins/refreshable.dart';
|
||||||
|
import '../../widgets/content_state_builder.dart';
|
||||||
|
|
||||||
/// Recommended tab for library screen
|
/// Recommended tab for library screen
|
||||||
/// Shows library-specific hubs and recommendations
|
/// Shows library-specific hubs and recommendations
|
||||||
@@ -109,53 +110,26 @@ class _LibraryRecommendedTabState extends State<LibraryRecommendedTab>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
super.build(context); // Required for AutomaticKeepAliveClientMixin
|
||||||
|
|
||||||
if (_isLoading && _hubs.isEmpty) {
|
return ContentStateBuilder<PlexHub>(
|
||||||
return const Center(child: CircularProgressIndicator());
|
isLoading: _isLoading,
|
||||||
}
|
errorMessage: _errorMessage,
|
||||||
|
items: _hubs,
|
||||||
if (_errorMessage != null && _hubs.isEmpty) {
|
emptyIcon: Icons.recommend,
|
||||||
return Center(
|
emptyMessage: t.libraries.noRecommendations,
|
||||||
child: Column(
|
onRetry: _loadHubs,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
builder: (items) => RefreshIndicator(
|
||||||
children: [
|
onRefresh: _loadHubs,
|
||||||
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
child: ListView.builder(
|
||||||
const SizedBox(height: 16),
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
Text(_errorMessage!),
|
itemCount: items.length,
|
||||||
const SizedBox(height: 16),
|
itemBuilder: (context, index) {
|
||||||
ElevatedButton(
|
final hub = items[index];
|
||||||
onPressed: _loadHubs,
|
return HubSection(
|
||||||
child: Text(t.common.retry),
|
hub: hub,
|
||||||
),
|
icon: _getHubIcon(hub),
|
||||||
],
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_hubs.isEmpty) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.recommend, size: 64, color: Colors.grey),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(t.libraries.noRecommendations),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: _loadHubs,
|
|
||||||
child: ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
||||||
itemCount: _hubs.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final hub = _hubs[index];
|
|
||||||
return HubSection(
|
|
||||||
hub: hub,
|
|
||||||
icon: _getHubIcon(hub),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,18 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../client/plex_client.dart';
|
|
||||||
import '../models/plex_playlist.dart';
|
import '../models/plex_playlist.dart';
|
||||||
import '../models/plex_metadata.dart';
|
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../providers/playback_state_provider.dart';
|
import '../providers/playback_state_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../utils/provider_extensions.dart';
|
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/collection_playlist_play_helper.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
|
import '../utils/grid_size_calculator.dart';
|
||||||
import '../widgets/media_card.dart';
|
import '../widgets/media_card.dart';
|
||||||
import '../widgets/playlist_item_card.dart';
|
import '../widgets/playlist_item_card.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../mixins/refreshable.dart';
|
|
||||||
import '../mixins/item_updatable.dart';
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
|
import '../utils/dialogs.dart';
|
||||||
|
import 'base_media_list_detail_screen.dart';
|
||||||
|
|
||||||
/// Screen to display the contents of a playlist
|
/// Screen to display the contents of a playlist
|
||||||
class PlaylistDetailScreen extends StatefulWidget {
|
class PlaylistDetailScreen extends StatefulWidget {
|
||||||
@@ -27,71 +24,56 @@ class PlaylistDetailScreen extends StatefulWidget {
|
|||||||
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
State<PlaylistDetailScreen> createState() => _PlaylistDetailScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
class _PlaylistDetailScreenState
|
||||||
with Refreshable, ItemUpdatable {
|
extends BaseMediaListDetailScreen<PlaylistDetailScreen> {
|
||||||
@override
|
@override
|
||||||
PlexClient get client => context.clientSafe;
|
dynamic get mediaItem => widget.playlist;
|
||||||
|
|
||||||
List<PlexMetadata> _items = [];
|
|
||||||
bool _isLoading = false;
|
|
||||||
String? _errorMessage;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
String get title => widget.playlist.title;
|
||||||
super.initState();
|
|
||||||
_loadPlaylistItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _loadPlaylistItems() async {
|
@override
|
||||||
setState(() {
|
String get emptyMessage => t.playlists.emptyPlaylist;
|
||||||
_isLoading = true;
|
|
||||||
_errorMessage = null;
|
@override
|
||||||
});
|
Future<void> loadItems() async {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final client = this.client;
|
||||||
final client = clientProvider.client;
|
final newItems = await client.getPlaylist(widget.playlist.ratingKey);
|
||||||
if (client == null) {
|
|
||||||
throw Exception(t.errors.noClientAvailable);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
items = newItems;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
final items = await client.getPlaylist(widget.playlist.ratingKey);
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
_items = items;
|
|
||||||
_isLoading = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
appLogger.d(
|
appLogger.d(
|
||||||
'Loaded ${items.length} items for playlist: ${widget.playlist.title}',
|
'Loaded ${newItems.length} items for playlist: ${widget.playlist.title}',
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
appLogger.e('Failed to load playlist items', error: e);
|
appLogger.e('Failed to load playlist items', error: e);
|
||||||
setState(() {
|
if (mounted) {
|
||||||
_errorMessage = 'Failed to load playlist items: ${e.toString()}';
|
setState(() {
|
||||||
_isLoading = false;
|
errorMessage = 'Failed to load playlist items: ${e.toString()}';
|
||||||
});
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deletePlaylist() async {
|
Future<void> _deletePlaylist() async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDeleteConfirmation(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => AlertDialog(
|
title: t.playlists.deleteConfirm,
|
||||||
title: Text(t.playlists.deleteConfirm),
|
message: t.playlists.deleteMessage(name: widget.playlist.title),
|
||||||
content: Text(t.playlists.deleteMessage(name: widget.playlist.title)),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: Text(t.playlists.delete),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && mounted) {
|
if (confirmed == true && mounted) {
|
||||||
@@ -121,7 +103,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
// Can't reorder if indices are the same
|
// Can't reorder if indices are the same
|
||||||
if (oldIndex == newIndex) return;
|
if (oldIndex == newIndex) return;
|
||||||
|
|
||||||
final movedItem = _items[oldIndex];
|
final movedItem = items[oldIndex];
|
||||||
|
|
||||||
// Check if item has playlistItemID (required for reordering)
|
// Check if item has playlistItemID (required for reordering)
|
||||||
if (movedItem.playlistItemID == null) {
|
if (movedItem.playlistItemID == null) {
|
||||||
@@ -141,7 +123,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
if (newIndex == 0) {
|
if (newIndex == 0) {
|
||||||
afterPlaylistItemId = 0; // Move to top
|
afterPlaylistItemId = 0; // Move to top
|
||||||
} else {
|
} else {
|
||||||
final afterItem = _items[newIndex - 1];
|
final afterItem = items[newIndex - 1];
|
||||||
if (afterItem.playlistItemID == null) {
|
if (afterItem.playlistItemID == null) {
|
||||||
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
appLogger.e('Cannot reorder: after item missing playlistItemID');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -160,8 +142,8 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
|
|
||||||
// Optimistically update UI
|
// Optimistically update UI
|
||||||
setState(() {
|
setState(() {
|
||||||
final item = _items.removeAt(oldIndex);
|
final item = items.removeAt(oldIndex);
|
||||||
_items.insert(newIndex, item);
|
items.insert(newIndex, item);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call API to persist the change
|
// Call API to persist the change
|
||||||
@@ -176,8 +158,8 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
appLogger.e('Failed to reorder playlist item, reverting UI');
|
appLogger.e('Failed to reorder playlist item, reverting UI');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() {
|
setState(() {
|
||||||
final item = _items.removeAt(newIndex);
|
final item = items.removeAt(newIndex);
|
||||||
_items.insert(oldIndex, item);
|
items.insert(oldIndex, item);
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -188,7 +170,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _removeItem(int index) async {
|
Future<void> _removeItem(int index) async {
|
||||||
final item = _items[index];
|
final item = items[index];
|
||||||
|
|
||||||
// Check if item has playlistItemID (required for removal)
|
// Check if item has playlistItemID (required for removal)
|
||||||
if (item.playlistItemID == null) {
|
if (item.playlistItemID == null) {
|
||||||
@@ -207,7 +189,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
|
|
||||||
// Optimistically update UI
|
// Optimistically update UI
|
||||||
setState(() {
|
setState(() {
|
||||||
_items.removeAt(index);
|
items.removeAt(index);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Call API to persist the change
|
// Call API to persist the change
|
||||||
@@ -225,7 +207,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
// Revert on failure
|
// Revert on failure
|
||||||
appLogger.e('Failed to remove playlist item, reverting UI');
|
appLogger.e('Failed to remove playlist item, reverting UI');
|
||||||
setState(() {
|
setState(() {
|
||||||
_items.insert(index, item);
|
items.insert(index, item);
|
||||||
});
|
});
|
||||||
|
|
||||||
ScaffoldMessenger.of(
|
ScaffoldMessenger.of(
|
||||||
@@ -235,72 +217,16 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
void updateItemInLists(String ratingKey, PlexMetadata updatedMetadata) {
|
|
||||||
final index = _items.indexWhere((item) => item.ratingKey == ratingKey);
|
|
||||||
if (index != -1) {
|
|
||||||
_items[index] = updatedMetadata;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void refresh() {
|
|
||||||
_loadPlaylistItems();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _playPlaylist() async {
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
|
||||||
context: context,
|
|
||||||
client: client,
|
|
||||||
item: widget.playlist,
|
|
||||||
shuffle: false,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _shufflePlayPlaylist() async {
|
|
||||||
if (_items.isEmpty) {
|
|
||||||
if (mounted) {
|
|
||||||
ScaffoldMessenger.of(
|
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text(t.playlists.emptyPlaylist)));
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final clientProvider = context.plexClient;
|
|
||||||
final client = clientProvider.client;
|
|
||||||
if (client == null) return;
|
|
||||||
|
|
||||||
await playCollectionOrPlaylist(
|
|
||||||
context: context,
|
|
||||||
client: client,
|
|
||||||
item: widget.playlist,
|
|
||||||
shuffle: true,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _playFromItem(int index) async {
|
Future<void> _playFromItem(int index) async {
|
||||||
if (_items.isEmpty || index < 0 || index >= _items.length) return;
|
if (items.isEmpty || index < 0 || index >= items.length) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
final clientProvider = context.plexClient;
|
final clientProvider = context.plexClient;
|
||||||
final client = clientProvider.client;
|
final client = clientProvider.client;
|
||||||
if (client == null) return;
|
if (client == null) return;
|
||||||
|
|
||||||
final selectedItem = _items[index];
|
final selectedItem = items[index];
|
||||||
|
|
||||||
// Create play queue from playlist, starting at the selected item
|
// Create play queue from playlist, starting at the selected item
|
||||||
final playQueue = await client.createPlayQueue(
|
final playQueue = await client.createPlayQueue(
|
||||||
@@ -385,18 +311,18 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
pinned: true,
|
pinned: true,
|
||||||
actions: [
|
actions: [
|
||||||
// Play button
|
// Play button
|
||||||
if (_items.isNotEmpty)
|
if (items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.play_arrow),
|
icon: const Icon(Icons.play_arrow),
|
||||||
tooltip: t.discover.play,
|
tooltip: t.discover.play,
|
||||||
onPressed: _playPlaylist,
|
onPressed: playItems,
|
||||||
),
|
),
|
||||||
// Shuffle button
|
// Shuffle button
|
||||||
if (_items.isNotEmpty)
|
if (items.isNotEmpty)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.shuffle),
|
icon: const Icon(Icons.shuffle),
|
||||||
tooltip: t.playlists.shuffle,
|
tooltip: t.playlists.shuffle,
|
||||||
onPressed: _shufflePlayPlaylist,
|
onPressed: shufflePlayItems,
|
||||||
),
|
),
|
||||||
// Delete button for non-smart playlists
|
// Delete button for non-smart playlists
|
||||||
if (!widget.playlist.smart)
|
if (!widget.playlist.smart)
|
||||||
@@ -408,7 +334,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
if (_errorMessage != null)
|
if (errorMessage != null)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -420,21 +346,21 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
color: Colors.red,
|
color: Colors.red,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(_errorMessage!),
|
Text(errorMessage!),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
ElevatedButton(
|
ElevatedButton(
|
||||||
onPressed: _loadPlaylistItems,
|
onPressed: loadItems,
|
||||||
child: Text(t.common.retry),
|
child: Text(t.common.retry),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else if (_items.isEmpty && _isLoading)
|
else if (items.isEmpty && isLoading)
|
||||||
const SliverFillRemaining(
|
const SliverFillRemaining(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
child: Center(child: CircularProgressIndicator()),
|
||||||
)
|
)
|
||||||
else if (_items.isEmpty)
|
else if (items.isEmpty)
|
||||||
SliverFillRemaining(
|
SliverFillRemaining(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -460,7 +386,7 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||||
context,
|
context,
|
||||||
context.watch<SettingsProvider>().libraryDensity,
|
context.watch<SettingsProvider>().libraryDensity,
|
||||||
),
|
),
|
||||||
@@ -469,15 +395,15 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
mainAxisSpacing: 0,
|
mainAxisSpacing: 0,
|
||||||
),
|
),
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
return MediaCard(item: _items[index], onRefresh: updateItem);
|
return MediaCard(item: items[index], onRefresh: updateItem);
|
||||||
}, childCount: _items.length),
|
}, childCount: items.length),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
// Regular playlists: Use reorderable list view
|
// Regular playlists: Use reorderable list view
|
||||||
SliverReorderableList(
|
SliverReorderableList(
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final item = _items[index];
|
final item = items[index];
|
||||||
return PlaylistItemCard(
|
return PlaylistItemCard(
|
||||||
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
key: ValueKey(item.playlistItemID ?? item.ratingKey),
|
||||||
item: item,
|
item: item,
|
||||||
@@ -487,75 +413,11 @@ class _PlaylistDetailScreenState extends State<PlaylistDetailScreen>
|
|||||||
canReorder: !widget.playlist.smart,
|
canReorder: !widget.playlist.smart,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
itemCount: _items.length,
|
itemCount: items.length,
|
||||||
onReorder: _onReorder,
|
onReorder: _onReorder,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
final padding = 16.0;
|
|
||||||
final availableWidth = screenWidth - padding;
|
|
||||||
|
|
||||||
if (screenWidth >= 900) {
|
|
||||||
double divisor;
|
|
||||||
double maxItemWidth;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 6.5;
|
|
||||||
maxItemWidth = 280;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 8.0;
|
|
||||||
maxItemWidth = 200;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 10.0;
|
|
||||||
maxItemWidth = 160;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (availableWidth / divisor).clamp(120, maxItemWidth);
|
|
||||||
} else if (screenWidth >= 600) {
|
|
||||||
double divisor;
|
|
||||||
double maxItemWidth;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 4.5;
|
|
||||||
maxItemWidth = 220;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 5.5;
|
|
||||||
maxItemWidth = 180;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 7.0;
|
|
||||||
maxItemWidth = 140;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (availableWidth / divisor).clamp(100, maxItemWidth);
|
|
||||||
} else {
|
|
||||||
double divisor;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 2.2;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 2.8;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 3.5;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return availableWidth / divisor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ import 'package:provider/provider.dart';
|
|||||||
import '../client/plex_client.dart';
|
import '../client/plex_client.dart';
|
||||||
import '../models/plex_playlist.dart';
|
import '../models/plex_playlist.dart';
|
||||||
import '../providers/settings_provider.dart';
|
import '../providers/settings_provider.dart';
|
||||||
import '../services/settings_service.dart';
|
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/grid_size_calculator.dart';
|
||||||
|
import '../utils/dialogs.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../mixins/refreshable.dart';
|
import '../mixins/refreshable.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
@@ -141,7 +142,7 @@ class _PlaylistsScreenState extends State<PlaylistsScreen> with Refreshable {
|
|||||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
|
||||||
sliver: SliverGrid(
|
sliver: SliverGrid(
|
||||||
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
maxCrossAxisExtent: _getMaxCrossAxisExtent(
|
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||||
context,
|
context,
|
||||||
context.watch<SettingsProvider>().libraryDensity,
|
context.watch<SettingsProvider>().libraryDensity,
|
||||||
),
|
),
|
||||||
@@ -170,70 +171,6 @@ class _PlaylistsScreenState extends State<PlaylistsScreen> with Refreshable {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
double _getMaxCrossAxisExtent(BuildContext context, LibraryDensity density) {
|
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
final padding = 16.0;
|
|
||||||
final availableWidth = screenWidth - padding;
|
|
||||||
|
|
||||||
if (screenWidth >= 900) {
|
|
||||||
double divisor;
|
|
||||||
double maxItemWidth;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 6.5;
|
|
||||||
maxItemWidth = 280;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 8.0;
|
|
||||||
maxItemWidth = 200;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 10.0;
|
|
||||||
maxItemWidth = 160;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (availableWidth / divisor).clamp(120, maxItemWidth);
|
|
||||||
} else if (screenWidth >= 600) {
|
|
||||||
double divisor;
|
|
||||||
double maxItemWidth;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 4.5;
|
|
||||||
maxItemWidth = 220;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 5.5;
|
|
||||||
maxItemWidth = 180;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 7.0;
|
|
||||||
maxItemWidth = 140;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (availableWidth / divisor).clamp(100, maxItemWidth);
|
|
||||||
} else {
|
|
||||||
double divisor;
|
|
||||||
|
|
||||||
switch (density) {
|
|
||||||
case LibraryDensity.comfortable:
|
|
||||||
divisor = 2.2;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.normal:
|
|
||||||
divisor = 2.8;
|
|
||||||
break;
|
|
||||||
case LibraryDensity.compact:
|
|
||||||
divisor = 3.5;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return availableWidth / divisor;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Widget to display a single playlist card
|
/// Widget to display a single playlist card
|
||||||
@@ -249,23 +186,10 @@ class _PlaylistCard extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Future<void> _showDeleteDialog(BuildContext context) async {
|
Future<void> _showDeleteDialog(BuildContext context) async {
|
||||||
final confirmed = await showDialog<bool>(
|
final confirmed = await showDeleteConfirmation(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => AlertDialog(
|
title: t.playlists.deleteConfirm,
|
||||||
title: Text(t.playlists.deleteConfirm),
|
message: t.playlists.deleteMessage(name: playlist.title),
|
||||||
content: Text(t.playlists.deleteMessage(name: playlist.title)),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, false),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.pop(context, true),
|
|
||||||
child: Text(t.playlists.delete),
|
|
||||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (confirmed == true && context.mounted) {
|
if (confirmed == true && context.mounted) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../services/plex_auth_service.dart';
|
import '../services/plex_auth_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
@@ -9,6 +10,7 @@ import '../widgets/server_list_tile.dart';
|
|||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
|
import '../utils/error_message_utils.dart';
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
|
|
||||||
class ServerSelectionScreen extends StatefulWidget {
|
class ServerSelectionScreen extends StatefulWidget {
|
||||||
@@ -74,6 +76,12 @@ class _ServerSelectionScreenState extends State<ServerSelectionScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _getErrorMessage(dynamic error) {
|
String _getErrorMessage(dynamic error) {
|
||||||
|
if (error is DioException) {
|
||||||
|
return mapDioErrorToMessage(
|
||||||
|
error,
|
||||||
|
context: t.serverSelection.noServersFound,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (error is ServerParsingException) {
|
if (error is ServerParsingException) {
|
||||||
return t.serverSelection.malformedServerData(
|
return t.serverSelection.malformedServerData(
|
||||||
count: error.invalidServerData.length,
|
count: error.invalidServerData.length,
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
|
||||||
|
/// Utility functions for showing common dialogs
|
||||||
|
|
||||||
|
/// Shows a delete confirmation dialog
|
||||||
|
/// Returns true if user confirmed, false if cancelled
|
||||||
|
Future<bool> showDeleteConfirmation(
|
||||||
|
BuildContext context, {
|
||||||
|
required String title,
|
||||||
|
required String message,
|
||||||
|
}) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(title),
|
||||||
|
content: Text(message),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(t.common.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||||
|
child: Text(t.common.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return confirmed ?? false;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
import 'app_logger.dart';
|
||||||
|
|
||||||
|
/// Shared helpers for translating network errors into user-friendly messages.
|
||||||
|
String mapDioErrorToMessage(DioException error, {required String context}) {
|
||||||
|
switch (error.type) {
|
||||||
|
case DioExceptionType.connectionTimeout:
|
||||||
|
case DioExceptionType.receiveTimeout:
|
||||||
|
return t.errors.connectionTimeout(context: context);
|
||||||
|
case DioExceptionType.connectionError:
|
||||||
|
return t.errors.connectionFailed;
|
||||||
|
default:
|
||||||
|
appLogger.e('Error loading $context', error: error);
|
||||||
|
return t.errors.failedToLoad(
|
||||||
|
context: context,
|
||||||
|
error: error.message ?? t.common.unknown,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generic fallback for unexpected errors.
|
||||||
|
String mapUnexpectedErrorToMessage(dynamic error, {required String context}) {
|
||||||
|
appLogger.e('Unexpected error in $context', error: error);
|
||||||
|
return t.errors.failedToLoad(
|
||||||
|
context: context,
|
||||||
|
error: error.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../models/plex_metadata.dart';
|
||||||
|
import '../providers/settings_provider.dart';
|
||||||
|
import '../services/settings_service.dart' show ViewMode;
|
||||||
|
import '../utils/grid_size_calculator.dart';
|
||||||
|
import 'media_card.dart';
|
||||||
|
|
||||||
|
/// A widget that automatically switches between grid and list view
|
||||||
|
/// based on user settings, providing a consistent layout pattern
|
||||||
|
/// across all library screens
|
||||||
|
class AdaptiveMediaGrid extends StatelessWidget {
|
||||||
|
/// The list of media items to display
|
||||||
|
final List<PlexMetadata> items;
|
||||||
|
|
||||||
|
/// Callback when the list needs to be refreshed
|
||||||
|
final VoidCallback? onRefresh;
|
||||||
|
|
||||||
|
/// Optional padding around the grid/list
|
||||||
|
final EdgeInsets padding;
|
||||||
|
|
||||||
|
/// Child aspect ratio for grid items (width / height)
|
||||||
|
final double childAspectRatio;
|
||||||
|
|
||||||
|
const AdaptiveMediaGrid({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
this.onRefresh,
|
||||||
|
this.padding = const EdgeInsets.fromLTRB(8, 8, 8, 8),
|
||||||
|
this.childAspectRatio = 2 / 3.3,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Consumer<SettingsProvider>(
|
||||||
|
builder: (context, settingsProvider, child) {
|
||||||
|
if (settingsProvider.viewMode == ViewMode.list) {
|
||||||
|
return ListView.builder(
|
||||||
|
padding: padding,
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return MediaCard(
|
||||||
|
key: Key(item.ratingKey),
|
||||||
|
item: item,
|
||||||
|
onListRefresh: onRefresh,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return GridView.builder(
|
||||||
|
padding: padding,
|
||||||
|
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
|
||||||
|
maxCrossAxisExtent: GridSizeCalculator.getMaxCrossAxisExtent(
|
||||||
|
context,
|
||||||
|
settingsProvider.libraryDensity,
|
||||||
|
),
|
||||||
|
childAspectRatio: childAspectRatio,
|
||||||
|
crossAxisSpacing: 0,
|
||||||
|
mainAxisSpacing: 0,
|
||||||
|
),
|
||||||
|
itemCount: items.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = items[index];
|
||||||
|
return MediaCard(
|
||||||
|
key: Key(item.ratingKey),
|
||||||
|
item: item,
|
||||||
|
onListRefresh: onRefresh,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../i18n/strings.g.dart';
|
||||||
|
|
||||||
|
/// A widget that handles loading, error, empty, and content states
|
||||||
|
/// Provides a consistent UI pattern across the app for data-driven screens
|
||||||
|
class ContentStateBuilder<T> extends StatelessWidget {
|
||||||
|
/// Whether data is currently loading
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
/// Error message to display (null if no error)
|
||||||
|
final String? errorMessage;
|
||||||
|
|
||||||
|
/// The list of items to display
|
||||||
|
final List<T> items;
|
||||||
|
|
||||||
|
/// Icon to display when the list is empty
|
||||||
|
final IconData emptyIcon;
|
||||||
|
|
||||||
|
/// Message to display when the list is empty
|
||||||
|
final String emptyMessage;
|
||||||
|
|
||||||
|
/// Callback when user taps retry button
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
|
||||||
|
/// Builder for the content when items are available
|
||||||
|
final Widget Function(List<T> items) builder;
|
||||||
|
|
||||||
|
const ContentStateBuilder({
|
||||||
|
super.key,
|
||||||
|
required this.isLoading,
|
||||||
|
required this.errorMessage,
|
||||||
|
required this.items,
|
||||||
|
required this.emptyIcon,
|
||||||
|
required this.emptyMessage,
|
||||||
|
required this.onRetry,
|
||||||
|
required this.builder,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// Loading state (only show loading indicator if items list is empty)
|
||||||
|
if (isLoading && items.isEmpty) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state (only show error if items list is empty)
|
||||||
|
if (errorMessage != null && items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.error_outline, size: 48, color: Colors.red),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
errorMessage!,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: onRetry,
|
||||||
|
child: Text(t.common.retry),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty state
|
||||||
|
if (items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(emptyIcon, size: 64, color: Colors.grey),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
emptyMessage,
|
||||||
|
style: const TextStyle(color: Colors.white70),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Content state - delegate to builder
|
||||||
|
return builder(items);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting audio tracks
|
/// Bottom sheet for selecting audio tracks
|
||||||
class AudioTrackSheet extends StatelessWidget {
|
class AudioTrackSheet extends StatelessWidget {
|
||||||
@@ -9,27 +10,13 @@ class AudioTrackSheet extends StatelessWidget {
|
|||||||
|
|
||||||
const AudioTrackSheet({super.key, required this.player, this.onTrackChanged});
|
const AudioTrackSheet({super.key, required this.player, this.onTrackChanged});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player, {
|
Player player, {
|
||||||
Function(AudioTrack)? onTrackChanged,
|
Function(AudioTrack)? onTrackChanged,
|
||||||
}) {
|
}) {
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
AudioTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
AudioTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
||||||
);
|
);
|
||||||
@@ -46,108 +33,73 @@ class AudioTrackSheet extends StatelessWidget {
|
|||||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return SafeArea(
|
return BaseVideoControlSheet(
|
||||||
child: SizedBox(
|
title: t.videoControls.audioLabel,
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
icon: Icons.audiotrack,
|
||||||
child: Column(
|
child: audioTracks.isEmpty
|
||||||
children: [
|
? const Center(
|
||||||
Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.all(16),
|
'No audio tracks available',
|
||||||
child: Row(
|
style: TextStyle(color: Colors.white70),
|
||||||
children: [
|
|
||||||
const Icon(Icons.audiotrack, color: Colors.white),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
t.videoControls.audioLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
const Divider(color: Colors.white24, height: 1),
|
: StreamBuilder<Track>(
|
||||||
if (audioTracks.isEmpty)
|
stream: player.stream.track,
|
||||||
const Expanded(
|
initialData: player.state.track,
|
||||||
child: Center(
|
builder: (context, selectedSnapshot) {
|
||||||
child: Text(
|
// Use snapshot data or fall back to current state
|
||||||
'No audio tracks available',
|
final currentTrack =
|
||||||
style: TextStyle(color: Colors.white70),
|
selectedSnapshot.data ?? player.state.track;
|
||||||
),
|
final selectedTrack = currentTrack.audio;
|
||||||
),
|
final selectedId = selectedTrack.id;
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: StreamBuilder<Track>(
|
|
||||||
stream: player.stream.track,
|
|
||||||
initialData: player.state.track,
|
|
||||||
builder: (context, selectedSnapshot) {
|
|
||||||
// Use snapshot data or fall back to current state
|
|
||||||
final currentTrack =
|
|
||||||
selectedSnapshot.data ?? player.state.track;
|
|
||||||
final selectedTrack = currentTrack.audio;
|
|
||||||
final selectedId = selectedTrack.id;
|
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount: audioTracks.length,
|
itemCount: audioTracks.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final audioTrack = audioTracks[index];
|
final audioTrack = audioTracks[index];
|
||||||
final isSelected = audioTrack.id == selectedId;
|
final isSelected = audioTrack.id == selectedId;
|
||||||
|
|
||||||
final parts = <String>[];
|
final parts = <String>[];
|
||||||
if (audioTrack.title != null &&
|
if (audioTrack.title != null &&
|
||||||
audioTrack.title!.isNotEmpty) {
|
audioTrack.title!.isNotEmpty) {
|
||||||
parts.add(audioTrack.title!);
|
parts.add(audioTrack.title!);
|
||||||
}
|
}
|
||||||
if (audioTrack.language != null &&
|
if (audioTrack.language != null &&
|
||||||
audioTrack.language!.isNotEmpty) {
|
audioTrack.language!.isNotEmpty) {
|
||||||
parts.add(audioTrack.language!.toUpperCase());
|
parts.add(audioTrack.language!.toUpperCase());
|
||||||
}
|
}
|
||||||
if (audioTrack.codec != null &&
|
if (audioTrack.codec != null &&
|
||||||
audioTrack.codec!.isNotEmpty) {
|
audioTrack.codec!.isNotEmpty) {
|
||||||
parts.add(audioTrack.codec!.toUpperCase());
|
parts.add(audioTrack.codec!.toUpperCase());
|
||||||
}
|
}
|
||||||
if (audioTrack.channelscount != null) {
|
if (audioTrack.channelscount != null) {
|
||||||
parts.add('${audioTrack.channelscount}ch');
|
parts.add('${audioTrack.channelscount}ch');
|
||||||
}
|
}
|
||||||
|
|
||||||
final label = parts.isEmpty
|
final label = parts.isEmpty
|
||||||
? 'Audio Track ${index + 1}'
|
? 'Audio Track ${index + 1}'
|
||||||
: parts.join(' · ');
|
: parts.join(' · ');
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected
|
color: isSelected ? Colors.blue : Colors.white,
|
||||||
? Colors.blue
|
),
|
||||||
: Colors.white,
|
),
|
||||||
),
|
trailing: isSelected
|
||||||
),
|
? const Icon(Icons.check, color: Colors.blue)
|
||||||
trailing: isSelected
|
: null,
|
||||||
? const Icon(Icons.check, color: Colors.blue)
|
onTap: () {
|
||||||
: null,
|
player.setAudioTrack(audioTrack);
|
||||||
onTap: () {
|
onTrackChanged?.call(audioTrack);
|
||||||
player.setAudioTrack(audioTrack);
|
Navigator.pop(context);
|
||||||
onTrackChanged?.call(audioTrack);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// Base class for video control bottom sheets providing common UI structure
|
||||||
|
class BaseVideoControlSheet extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final IconData icon;
|
||||||
|
final Widget child;
|
||||||
|
final Color? iconColor;
|
||||||
|
|
||||||
|
const BaseVideoControlSheet({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.icon,
|
||||||
|
required this.child,
|
||||||
|
this.iconColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Get consistent bottom sheet constraints across all video control sheets
|
||||||
|
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
||||||
|
final size = MediaQuery.of(context).size;
|
||||||
|
final isDesktop = size.width > 600;
|
||||||
|
|
||||||
|
return BoxConstraints(
|
||||||
|
maxWidth: isDesktop ? 700 : double.infinity,
|
||||||
|
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
||||||
|
minHeight: isDesktop ? 300 : size.height * 0.5,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper method to show a modal bottom sheet with consistent styling
|
||||||
|
static Future<T?> showSheet<T>({
|
||||||
|
required BuildContext context,
|
||||||
|
required WidgetBuilder builder,
|
||||||
|
}) {
|
||||||
|
return showModalBottomSheet<T>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.grey[900],
|
||||||
|
isScrollControlled: true,
|
||||||
|
constraints: getBottomSheetConstraints(context),
|
||||||
|
builder: builder,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: SizedBox(
|
||||||
|
height: MediaQuery.of(context).size.height * 0.75,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
_buildHeader(context),
|
||||||
|
const Divider(color: Colors.white24, height: 1),
|
||||||
|
Expanded(child: child),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHeader(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(icon, color: iconColor ?? Colors.white),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Spacer(),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import 'package:media_kit/media_kit.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../../models/plex_media_info.dart';
|
import '../../../models/plex_media_info.dart';
|
||||||
import '../../../providers/plex_client_provider.dart';
|
import '../../../providers/plex_client_provider.dart';
|
||||||
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting chapters
|
/// Bottom sheet for selecting chapters
|
||||||
class ChapterSheet extends StatelessWidget {
|
class ChapterSheet extends StatelessWidget {
|
||||||
@@ -17,28 +18,14 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
required this.chaptersLoaded,
|
required this.chaptersLoaded,
|
||||||
});
|
});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player,
|
Player player,
|
||||||
List<PlexChapter> chapters,
|
List<PlexChapter> chapters,
|
||||||
bool chaptersLoaded,
|
bool chaptersLoaded,
|
||||||
) {
|
) {
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) => ChapterSheet(
|
builder: (context) => ChapterSheet(
|
||||||
player: player,
|
player: player,
|
||||||
chapters: chapters,
|
chapters: chapters,
|
||||||
@@ -73,8 +60,7 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
for (int i = 0; i < chapters.length; i++) {
|
for (int i = 0; i < chapters.length; i++) {
|
||||||
final chapter = chapters[i];
|
final chapter = chapters[i];
|
||||||
final startMs = chapter.startTimeOffset ?? 0;
|
final startMs = chapter.startTimeOffset ?? 0;
|
||||||
final endMs =
|
final endMs = chapter.endTimeOffset ??
|
||||||
chapter.endTimeOffset ??
|
|
||||||
(i < chapters.length - 1
|
(i < chapters.length - 1
|
||||||
? chapters[i + 1].startTimeOffset ?? 0
|
? chapters[i + 1].startTimeOffset ?? 0
|
||||||
: double.maxFinite.toInt());
|
: double.maxFinite.toInt());
|
||||||
@@ -85,148 +71,114 @@ class ChapterSheet extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return SafeArea(
|
Widget content;
|
||||||
child: SizedBox(
|
if (!chaptersLoaded) {
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
content = const Center(child: CircularProgressIndicator());
|
||||||
child: Column(
|
} else if (chapters.isEmpty) {
|
||||||
children: [
|
content = const Center(
|
||||||
Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.all(16),
|
'No chapters available',
|
||||||
child: Row(
|
style: TextStyle(color: Colors.white70),
|
||||||
children: [
|
),
|
||||||
const Icon(Icons.video_library, color: Colors.white),
|
);
|
||||||
const SizedBox(width: 12),
|
} else {
|
||||||
const Text(
|
content = ListView.builder(
|
||||||
'Chapters',
|
itemCount: chapters.length,
|
||||||
style: TextStyle(
|
itemBuilder: (context, index) {
|
||||||
color: Colors.white,
|
final chapter = chapters[index];
|
||||||
fontSize: 18,
|
final isCurrentChapter = currentChapterIndex == index;
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
return ListTile(
|
||||||
),
|
leading: chapter.thumb != null
|
||||||
const Spacer(),
|
? Stack(
|
||||||
IconButton(
|
children: [
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
ClipRRect(
|
||||||
onPressed: () => Navigator.pop(context),
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
child: Consumer<PlexClientProvider>(
|
||||||
],
|
builder: (context, clientProvider, child) {
|
||||||
|
final client = clientProvider.client;
|
||||||
|
if (client == null) {
|
||||||
|
return const Icon(
|
||||||
|
Icons.image,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: 34,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Image.network(
|
||||||
|
client.getThumbnailUrl(
|
||||||
|
chapter.thumb,
|
||||||
|
),
|
||||||
|
width: 60,
|
||||||
|
height: 34,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (
|
||||||
|
context,
|
||||||
|
error,
|
||||||
|
stackTrace,
|
||||||
|
) =>
|
||||||
|
const Icon(
|
||||||
|
Icons.image,
|
||||||
|
color: Colors.white54,
|
||||||
|
size: 34,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (isCurrentChapter)
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
4,
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.blue,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
title: Text(
|
||||||
|
chapter.label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isCurrentChapter ? Colors.blue : Colors.white,
|
||||||
|
fontWeight: isCurrentChapter
|
||||||
|
? FontWeight.bold
|
||||||
|
: FontWeight.normal,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(color: Colors.white24, height: 1),
|
subtitle: Text(
|
||||||
if (!chaptersLoaded)
|
_formatDuration(chapter.startTime),
|
||||||
const Expanded(
|
style: TextStyle(
|
||||||
child: Center(child: CircularProgressIndicator()),
|
color: isCurrentChapter
|
||||||
)
|
? Colors.blue.withValues(alpha: 0.7)
|
||||||
else if (chapters.isEmpty)
|
: Colors.white70,
|
||||||
const Expanded(
|
fontSize: 12,
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
'No chapters available',
|
|
||||||
style: TextStyle(color: Colors.white70),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: chapters.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final chapter = chapters[index];
|
|
||||||
final isCurrentChapter = currentChapterIndex == index;
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: chapter.thumb != null
|
|
||||||
? Stack(
|
|
||||||
children: [
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
child: Consumer<PlexClientProvider>(
|
|
||||||
builder:
|
|
||||||
(context, clientProvider, child) {
|
|
||||||
final client =
|
|
||||||
clientProvider.client;
|
|
||||||
if (client == null) {
|
|
||||||
return const Icon(
|
|
||||||
Icons.image,
|
|
||||||
color: Colors.white54,
|
|
||||||
size: 34,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Image.network(
|
|
||||||
client.getThumbnailUrl(
|
|
||||||
chapter.thumb,
|
|
||||||
),
|
|
||||||
width: 60,
|
|
||||||
height: 34,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder:
|
|
||||||
(
|
|
||||||
context,
|
|
||||||
error,
|
|
||||||
stackTrace,
|
|
||||||
) => const Icon(
|
|
||||||
Icons.image,
|
|
||||||
color: Colors.white54,
|
|
||||||
size: 34,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (isCurrentChapter)
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(
|
|
||||||
4,
|
|
||||||
),
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.blue,
|
|
||||||
width: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
title: Text(
|
|
||||||
chapter.label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isCurrentChapter
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.white,
|
|
||||||
fontWeight: isCurrentChapter
|
|
||||||
? FontWeight.bold
|
|
||||||
: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
subtitle: Text(
|
|
||||||
_formatDuration(chapter.startTime),
|
|
||||||
style: TextStyle(
|
|
||||||
color: isCurrentChapter
|
|
||||||
? Colors.blue.withValues(alpha: 0.7)
|
|
||||||
: Colors.white70,
|
|
||||||
fontSize: 12,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isCurrentChapter
|
|
||||||
? const Icon(
|
|
||||||
Icons.play_circle_filled,
|
|
||||||
color: Colors.blue,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
player.seek(chapter.startTime);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
trailing: isCurrentChapter
|
||||||
),
|
? const Icon(
|
||||||
|
Icons.play_circle_filled,
|
||||||
|
color: Colors.blue,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
player.seek(chapter.startTime);
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return BaseVideoControlSheet(
|
||||||
|
title: 'Chapters',
|
||||||
|
icon: Icons.video_library,
|
||||||
|
child: content,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting playback speed
|
/// Bottom sheet for selecting playback speed
|
||||||
class PlaybackSpeedSheet extends StatelessWidget {
|
class PlaybackSpeedSheet extends StatelessWidget {
|
||||||
@@ -7,23 +8,9 @@ class PlaybackSpeedSheet extends StatelessWidget {
|
|||||||
|
|
||||||
const PlaybackSpeedSheet({super.key, required this.player});
|
const PlaybackSpeedSheet({super.key, required this.player});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(BuildContext context, Player player) {
|
static void show(BuildContext context, Player player) {
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) => PlaybackSpeedSheet(player: player),
|
builder: (context) => PlaybackSpeedSheet(player: player),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -39,66 +26,35 @@ class PlaybackSpeedSheet extends StatelessWidget {
|
|||||||
// Define available playback speeds
|
// Define available playback speeds
|
||||||
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
final speeds = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0, 2.5, 3.0];
|
||||||
|
|
||||||
return SafeArea(
|
return BaseVideoControlSheet(
|
||||||
child: SizedBox(
|
title: 'Playback Speed',
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
icon: Icons.speed,
|
||||||
child: Column(
|
child: ListView.builder(
|
||||||
children: [
|
itemCount: speeds.length,
|
||||||
Padding(
|
itemBuilder: (context, index) {
|
||||||
padding: const EdgeInsets.all(16),
|
final speed = speeds[index];
|
||||||
child: Row(
|
final isSelected = (currentRate - speed).abs() < 0.01;
|
||||||
children: [
|
|
||||||
const Icon(Icons.speed, color: Colors.white),
|
// Format speed label
|
||||||
const SizedBox(width: 12),
|
final label =
|
||||||
const Text(
|
speed == 1.0 ? 'Normal' : '${speed.toStringAsFixed(2)}x';
|
||||||
'Playback Speed',
|
|
||||||
style: TextStyle(
|
return ListTile(
|
||||||
color: Colors.white,
|
title: Text(
|
||||||
fontSize: 18,
|
label,
|
||||||
fontWeight: FontWeight.bold,
|
style: TextStyle(
|
||||||
),
|
color: isSelected ? Colors.blue : Colors.white,
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Divider(color: Colors.white24, height: 1),
|
trailing: isSelected
|
||||||
Expanded(
|
? const Icon(Icons.check, color: Colors.blue)
|
||||||
child: ListView.builder(
|
: null,
|
||||||
itemCount: speeds.length,
|
onTap: () {
|
||||||
itemBuilder: (context, index) {
|
player.setRate(speed);
|
||||||
final speed = speeds[index];
|
Navigator.pop(context);
|
||||||
final isSelected = (currentRate - speed).abs() < 0.01;
|
},
|
||||||
|
);
|
||||||
// Format speed label
|
},
|
||||||
final label = speed == 1.0
|
|
||||||
? 'Normal'
|
|
||||||
: '${speed.toStringAsFixed(2)}x';
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
title: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected ? Colors.blue : Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check, color: Colors.blue)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
player.setRate(speed);
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,7 +2,8 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../services/sleep_timer_service.dart';
|
import '../../../services/sleep_timer_service.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import 'base_video_control_sheet.dart';
|
||||||
|
import '../widgets/sleep_timer_content.dart';
|
||||||
|
|
||||||
/// Bottom sheet for sleep timer configuration
|
/// Bottom sheet for sleep timer configuration
|
||||||
class SleepTimerSheet extends StatelessWidget {
|
class SleepTimerSheet extends StatelessWidget {
|
||||||
@@ -15,47 +16,19 @@ class SleepTimerSheet extends StatelessWidget {
|
|||||||
required this.defaultDuration,
|
required this.defaultDuration,
|
||||||
});
|
});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(BuildContext context, Player player) async {
|
static void show(BuildContext context, Player player) async {
|
||||||
final settingsService = await SettingsService.getInstance();
|
final settingsService = await SettingsService.getInstance();
|
||||||
final defaultDuration = settingsService.getSleepTimerDuration();
|
final defaultDuration = settingsService.getSleepTimerDuration();
|
||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
|
SleepTimerSheet(player: player, defaultDuration: defaultDuration),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatSleepTimerDuration(Duration duration) {
|
|
||||||
final hours = duration.inHours;
|
|
||||||
final minutes = duration.inMinutes.remainder(60);
|
|
||||||
final seconds = duration.inSeconds.remainder(60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return '${hours}h ${minutes}m ${seconds}s';
|
|
||||||
} else if (minutes > 0) {
|
|
||||||
return '${minutes}m ${seconds}s';
|
|
||||||
} else {
|
|
||||||
return '${seconds}s';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sleepTimer = SleepTimerService();
|
final sleepTimer = SleepTimerService();
|
||||||
@@ -63,169 +36,15 @@ class SleepTimerSheet extends StatelessWidget {
|
|||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: sleepTimer,
|
listenable: sleepTimer,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
return BaseVideoControlSheet(
|
||||||
// Add default duration if not in list
|
title: 'Sleep Timer',
|
||||||
if (!durations.contains(defaultDuration)) {
|
icon: sleepTimer.isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||||
durations.add(defaultDuration);
|
iconColor: sleepTimer.isActive ? Colors.amber : null,
|
||||||
durations.sort();
|
child: SleepTimerContent(
|
||||||
}
|
player: player,
|
||||||
final remainingTime = sleepTimer.remainingTime;
|
sleepTimer: sleepTimer,
|
||||||
|
defaultDuration: defaultDuration,
|
||||||
return SafeArea(
|
onCancel: () => Navigator.pop(context),
|
||||||
child: SizedBox(
|
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Icon(
|
|
||||||
sleepTimer.isActive
|
|
||||||
? Icons.bedtime
|
|
||||||
: Icons.bedtime_outlined,
|
|
||||||
color: sleepTimer.isActive
|
|
||||||
? Colors.amber
|
|
||||||
: Colors.white,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Text(
|
|
||||||
'Sleep Timer',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.white24, height: 1),
|
|
||||||
|
|
||||||
// Show current timer status if active
|
|
||||||
if (sleepTimer.isActive && remainingTime != null) ...[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
color: Colors.amber.withValues(alpha: 0.1),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Timer Active',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.amber,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: Text(
|
|
||||||
t.videoControls.addTime(
|
|
||||||
amount: "15",
|
|
||||||
unit: " min",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
side: const BorderSide(color: Colors.white54),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
sleepTimer.extendTimer(
|
|
||||||
const Duration(minutes: 15),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
FilledButton.icon(
|
|
||||||
icon: const Icon(Icons.cancel),
|
|
||||||
label: Text(t.common.cancel),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
sleepTimer.cancelTimer();
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.white24, height: 1),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Duration selection list
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: durations.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final minutes = durations[index];
|
|
||||||
final label = minutes < 60
|
|
||||||
? '$minutes minutes'
|
|
||||||
: '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.timer, color: Colors.white70),
|
|
||||||
title: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontWeight: FontWeight.normal,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
|
||||||
// Pause playback when timer completes
|
|
||||||
player.pause();
|
|
||||||
|
|
||||||
// Show a snackbar notification
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Sleep timer completed - playback paused',
|
|
||||||
),
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Navigator.pop(context);
|
|
||||||
|
|
||||||
// Show confirmation snackbar
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(
|
|
||||||
t.messages.sleepTimerSet(label: label),
|
|
||||||
),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:media_kit/media_kit.dart';
|
import 'package:media_kit/media_kit.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting subtitle tracks
|
/// Bottom sheet for selecting subtitle tracks
|
||||||
class SubtitleTrackSheet extends StatelessWidget {
|
class SubtitleTrackSheet extends StatelessWidget {
|
||||||
@@ -13,27 +14,13 @@ class SubtitleTrackSheet extends StatelessWidget {
|
|||||||
this.onTrackChanged,
|
this.onTrackChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
Player player, {
|
Player player, {
|
||||||
Function(SubtitleTrack)? onTrackChanged,
|
Function(SubtitleTrack)? onTrackChanged,
|
||||||
}) {
|
}) {
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) =>
|
builder: (context) =>
|
||||||
SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
SubtitleTrackSheet(player: player, onTrackChanged: onTrackChanged),
|
||||||
);
|
);
|
||||||
@@ -50,146 +37,108 @@ class SubtitleTrackSheet extends StatelessWidget {
|
|||||||
.where((track) => track.id != 'auto' && track.id != 'no')
|
.where((track) => track.id != 'auto' && track.id != 'no')
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
return SafeArea(
|
return BaseVideoControlSheet(
|
||||||
child: SizedBox(
|
title: t.videoControls.subtitlesLabel,
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
icon: Icons.subtitles,
|
||||||
child: Column(
|
child: subtitles.isEmpty
|
||||||
children: [
|
? const Center(
|
||||||
Padding(
|
child: Text(
|
||||||
padding: const EdgeInsets.all(16),
|
'No subtitles available',
|
||||||
child: Row(
|
style: TextStyle(color: Colors.white70),
|
||||||
children: [
|
|
||||||
const Icon(Icons.subtitles, color: Colors.white),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Text(
|
|
||||||
t.videoControls.subtitlesLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
)
|
||||||
const Divider(color: Colors.white24, height: 1),
|
: StreamBuilder<Track>(
|
||||||
if (subtitles.isEmpty)
|
stream: player.stream.track,
|
||||||
const Expanded(
|
initialData: player.state.track,
|
||||||
child: Center(
|
builder: (context, selectedSnapshot) {
|
||||||
child: Text(
|
// Use snapshot data or fall back to current state
|
||||||
'No subtitles available',
|
final currentTrack =
|
||||||
style: TextStyle(color: Colors.white70),
|
selectedSnapshot.data ?? player.state.track;
|
||||||
),
|
final selectedTrack = currentTrack.subtitle;
|
||||||
),
|
final selectedId = selectedTrack.id;
|
||||||
)
|
final isOffSelected = selectedId == 'no';
|
||||||
else
|
|
||||||
Expanded(
|
|
||||||
child: StreamBuilder<Track>(
|
|
||||||
stream: player.stream.track,
|
|
||||||
initialData: player.state.track,
|
|
||||||
builder: (context, selectedSnapshot) {
|
|
||||||
// Use snapshot data or fall back to current state
|
|
||||||
final currentTrack =
|
|
||||||
selectedSnapshot.data ?? player.state.track;
|
|
||||||
final selectedTrack = currentTrack.subtitle;
|
|
||||||
final selectedId = selectedTrack.id;
|
|
||||||
final isOffSelected = selectedId == 'no';
|
|
||||||
|
|
||||||
return ListView.builder(
|
return ListView.builder(
|
||||||
itemCount:
|
itemCount: subtitles.length + 1, // +1 for "Off" option
|
||||||
subtitles.length + 1, // +1 for "Off" option
|
itemBuilder: (context, index) {
|
||||||
itemBuilder: (context, index) {
|
// First item is "Off"
|
||||||
// First item is "Off"
|
if (index == 0) {
|
||||||
if (index == 0) {
|
return ListTile(
|
||||||
return ListTile(
|
title: Text(
|
||||||
title: Text(
|
'Off',
|
||||||
'Off',
|
style: TextStyle(
|
||||||
style: TextStyle(
|
color:
|
||||||
color: isOffSelected
|
isOffSelected ? Colors.blue : Colors.white,
|
||||||
? Colors.blue
|
|
||||||
: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isOffSelected
|
|
||||||
? const Icon(
|
|
||||||
Icons.check,
|
|
||||||
color: Colors.blue,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
player.setSubtitleTrack(SubtitleTrack.no());
|
|
||||||
onTrackChanged?.call(SubtitleTrack.no());
|
|
||||||
Navigator.pop(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subsequent items are subtitle tracks
|
|
||||||
final subtitle = subtitles[index - 1];
|
|
||||||
final isSelected = subtitle.id == selectedId;
|
|
||||||
|
|
||||||
// Build label with available info
|
|
||||||
final parts = <String>[];
|
|
||||||
if (subtitle.title != null &&
|
|
||||||
subtitle.title!.isNotEmpty) {
|
|
||||||
parts.add(subtitle.title!);
|
|
||||||
}
|
|
||||||
if (subtitle.language != null &&
|
|
||||||
subtitle.language!.isNotEmpty) {
|
|
||||||
parts.add(subtitle.language!.toUpperCase());
|
|
||||||
}
|
|
||||||
if (subtitle.codec != null &&
|
|
||||||
subtitle.codec!.isNotEmpty) {
|
|
||||||
// Format codec names nicely
|
|
||||||
String codecName = subtitle.codec!.toUpperCase();
|
|
||||||
if (codecName == 'SUBRIP') {
|
|
||||||
codecName = 'SRT';
|
|
||||||
} else if (codecName == 'DVD_SUBTITLE') {
|
|
||||||
codecName = 'DVD';
|
|
||||||
} else if (codecName == 'ASS' ||
|
|
||||||
codecName == 'SSA') {
|
|
||||||
codecName = codecName; // Keep as-is
|
|
||||||
} else if (codecName == 'WEBVTT') {
|
|
||||||
codecName = 'VTT';
|
|
||||||
}
|
|
||||||
parts.add(codecName);
|
|
||||||
}
|
|
||||||
|
|
||||||
final label = parts.isEmpty
|
|
||||||
? 'Track $index'
|
|
||||||
: parts.join(' · ');
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
title: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
color: isSelected
|
|
||||||
? Colors.blue
|
|
||||||
: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
trailing: isSelected
|
),
|
||||||
? const Icon(Icons.check, color: Colors.blue)
|
trailing: isOffSelected
|
||||||
: null,
|
? const Icon(
|
||||||
onTap: () {
|
Icons.check,
|
||||||
player.setSubtitleTrack(subtitle);
|
color: Colors.blue,
|
||||||
onTrackChanged?.call(subtitle);
|
)
|
||||||
Navigator.pop(context);
|
: null,
|
||||||
},
|
onTap: () {
|
||||||
);
|
player.setSubtitleTrack(SubtitleTrack.no());
|
||||||
|
onTrackChanged?.call(SubtitleTrack.no());
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Subsequent items are subtitle tracks
|
||||||
|
final subtitle = subtitles[index - 1];
|
||||||
|
final isSelected = subtitle.id == selectedId;
|
||||||
|
|
||||||
|
// Build label with available info
|
||||||
|
final parts = <String>[];
|
||||||
|
if (subtitle.title != null &&
|
||||||
|
subtitle.title!.isNotEmpty) {
|
||||||
|
parts.add(subtitle.title!);
|
||||||
|
}
|
||||||
|
if (subtitle.language != null &&
|
||||||
|
subtitle.language!.isNotEmpty) {
|
||||||
|
parts.add(subtitle.language!.toUpperCase());
|
||||||
|
}
|
||||||
|
if (subtitle.codec != null &&
|
||||||
|
subtitle.codec!.isNotEmpty) {
|
||||||
|
// Format codec names nicely
|
||||||
|
String codecName = subtitle.codec!.toUpperCase();
|
||||||
|
if (codecName == 'SUBRIP') {
|
||||||
|
codecName = 'SRT';
|
||||||
|
} else if (codecName == 'DVD_SUBTITLE') {
|
||||||
|
codecName = 'DVD';
|
||||||
|
} else if (codecName == 'ASS' ||
|
||||||
|
codecName == 'SSA') {
|
||||||
|
codecName = codecName; // Keep as-is
|
||||||
|
} else if (codecName == 'WEBVTT') {
|
||||||
|
codecName = 'VTT';
|
||||||
|
}
|
||||||
|
parts.add(codecName);
|
||||||
|
}
|
||||||
|
|
||||||
|
final label =
|
||||||
|
parts.isEmpty ? 'Track $index' : parts.join(' · ');
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isSelected ? Colors.blue : Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
trailing: isSelected
|
||||||
|
? const Icon(Icons.check, color: Colors.blue)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
player.setSubtitleTrack(subtitle);
|
||||||
|
onTrackChanged?.call(subtitle);
|
||||||
|
Navigator.pop(context);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../../models/plex_media_version.dart';
|
import '../../../models/plex_media_version.dart';
|
||||||
|
import 'base_video_control_sheet.dart';
|
||||||
|
|
||||||
/// Bottom sheet for selecting video version
|
/// Bottom sheet for selecting video version
|
||||||
class VersionSheet extends StatelessWidget {
|
class VersionSheet extends StatelessWidget {
|
||||||
@@ -14,28 +15,14 @@ class VersionSheet extends StatelessWidget {
|
|||||||
required this.onVersionSelected,
|
required this.onVersionSelected,
|
||||||
});
|
});
|
||||||
|
|
||||||
static BoxConstraints getBottomSheetConstraints(BuildContext context) {
|
|
||||||
final size = MediaQuery.of(context).size;
|
|
||||||
final isDesktop = size.width > 600;
|
|
||||||
|
|
||||||
return BoxConstraints(
|
|
||||||
maxWidth: isDesktop ? 700 : double.infinity,
|
|
||||||
maxHeight: isDesktop ? 400 : size.height * 0.75,
|
|
||||||
minHeight: isDesktop ? 300 : size.height * 0.5,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void show(
|
static void show(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
List<PlexMediaVersion> availableVersions,
|
List<PlexMediaVersion> availableVersions,
|
||||||
int selectedMediaIndex,
|
int selectedMediaIndex,
|
||||||
Function(int) onVersionSelected,
|
Function(int) onVersionSelected,
|
||||||
) {
|
) {
|
||||||
showModalBottomSheet(
|
BaseVideoControlSheet.showSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.grey[900],
|
|
||||||
isScrollControlled: true,
|
|
||||||
constraints: getBottomSheetConstraints(context),
|
|
||||||
builder: (context) => VersionSheet(
|
builder: (context) => VersionSheet(
|
||||||
availableVersions: availableVersions,
|
availableVersions: availableVersions,
|
||||||
selectedMediaIndex: selectedMediaIndex,
|
selectedMediaIndex: selectedMediaIndex,
|
||||||
@@ -46,61 +33,31 @@ class VersionSheet extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return SafeArea(
|
return BaseVideoControlSheet(
|
||||||
child: SizedBox(
|
title: 'Video Version',
|
||||||
height: MediaQuery.of(context).size.height * 0.75,
|
icon: Icons.video_file,
|
||||||
child: Column(
|
child: ListView.builder(
|
||||||
children: [
|
itemCount: availableVersions.length,
|
||||||
Padding(
|
itemBuilder: (context, index) {
|
||||||
padding: const EdgeInsets.all(16),
|
final version = availableVersions[index];
|
||||||
child: Row(
|
final isSelected = index == selectedMediaIndex;
|
||||||
children: [
|
|
||||||
const Icon(Icons.video_file, color: Colors.white),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
const Text(
|
|
||||||
'Video Version',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Spacer(),
|
|
||||||
IconButton(
|
|
||||||
icon: const Icon(Icons.close, color: Colors.white),
|
|
||||||
onPressed: () => Navigator.pop(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.white24, height: 1),
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: availableVersions.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final version = availableVersions[index];
|
|
||||||
final isSelected = index == selectedMediaIndex;
|
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
title: Text(
|
title: Text(
|
||||||
version.displayLabel,
|
version.displayLabel,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: isSelected ? Colors.blue : Colors.white,
|
color: isSelected ? Colors.blue : Colors.white,
|
||||||
),
|
|
||||||
),
|
|
||||||
trailing: isSelected
|
|
||||||
? const Icon(Icons.check, color: Colors.blue)
|
|
||||||
: null,
|
|
||||||
onTap: () {
|
|
||||||
Navigator.pop(context);
|
|
||||||
onVersionSelected(index);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
trailing: isSelected
|
||||||
),
|
? const Icon(Icons.check, color: Colors.blue)
|
||||||
|
: null,
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
onVersionSelected(index);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,65 @@ import '../../../services/settings_service.dart';
|
|||||||
import '../../../services/sleep_timer_service.dart';
|
import '../../../services/sleep_timer_service.dart';
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
import '../widgets/sync_offset_control.dart';
|
import '../widgets/sync_offset_control.dart';
|
||||||
|
import '../widgets/sleep_timer_content.dart';
|
||||||
import '../../../i18n/strings.g.dart';
|
import '../../../i18n/strings.g.dart';
|
||||||
|
|
||||||
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
|
enum _SettingsView { menu, speed, sleep, audioSync, subtitleSync, audioDevice }
|
||||||
|
|
||||||
|
/// Reusable menu item widget for settings sheet
|
||||||
|
class _SettingsMenuItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String valueText;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool isHighlighted;
|
||||||
|
final bool allowValueOverflow;
|
||||||
|
|
||||||
|
const _SettingsMenuItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.valueText,
|
||||||
|
required this.onTap,
|
||||||
|
this.isHighlighted = false,
|
||||||
|
this.allowValueOverflow = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final valueWidget = Text(
|
||||||
|
valueText,
|
||||||
|
style: TextStyle(
|
||||||
|
color: isHighlighted ? Colors.amber : Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
overflow: allowValueOverflow ? TextOverflow.ellipsis : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
icon,
|
||||||
|
color: isHighlighted ? Colors.amber : Colors.white70,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
title,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
if (allowValueOverflow)
|
||||||
|
Flexible(child: valueWidget)
|
||||||
|
else
|
||||||
|
valueWidget,
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(Icons.chevron_right, color: Colors.white70),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
/// Unified settings sheet for playback adjustments with in-sheet navigation
|
||||||
class VideoSettingsSheet extends StatefulWidget {
|
class VideoSettingsSheet extends StatefulWidget {
|
||||||
final Player player;
|
final Player player;
|
||||||
@@ -139,19 +194,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatSleepTimerDuration(Duration duration) {
|
|
||||||
final hours = duration.inHours;
|
|
||||||
final minutes = duration.inMinutes.remainder(60);
|
|
||||||
final seconds = duration.inSeconds.remainder(60);
|
|
||||||
|
|
||||||
if (hours > 0) {
|
|
||||||
return '${hours}h ${minutes}m ${seconds}s';
|
|
||||||
} else if (minutes > 0) {
|
|
||||||
return '${minutes}m ${seconds}s';
|
|
||||||
} else {
|
|
||||||
return '${seconds}s';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader() {
|
Widget _buildHeader() {
|
||||||
final sleepTimer = SleepTimerService();
|
final sleepTimer = SleepTimerService();
|
||||||
@@ -204,23 +246,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
initialData: widget.player.state.rate,
|
initialData: widget.player.state.rate,
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
final currentRate = snapshot.data ?? 1.0;
|
final currentRate = snapshot.data ?? 1.0;
|
||||||
return ListTile(
|
return _SettingsMenuItem(
|
||||||
leading: const Icon(Icons.speed, color: Colors.white70),
|
icon: Icons.speed,
|
||||||
title: const Text(
|
title: 'Playback Speed',
|
||||||
'Playback Speed',
|
valueText: _formatSpeed(currentRate),
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatSpeed(currentRate),
|
|
||||||
style: const TextStyle(color: Colors.white70, fontSize: 14),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => _navigateTo(_SettingsView.speed),
|
onTap: () => _navigateTo(_SettingsView.speed),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -231,87 +260,31 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
listenable: sleepTimer,
|
listenable: sleepTimer,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final isActive = sleepTimer.isActive;
|
final isActive = sleepTimer.isActive;
|
||||||
return ListTile(
|
return _SettingsMenuItem(
|
||||||
leading: Icon(
|
icon: isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
||||||
isActive ? Icons.bedtime : Icons.bedtime_outlined,
|
title: 'Sleep Timer',
|
||||||
color: isActive ? Colors.amber : Colors.white70,
|
valueText: _formatSleepTimer(sleepTimer),
|
||||||
),
|
isHighlighted: isActive,
|
||||||
title: const Text(
|
|
||||||
'Sleep Timer',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatSleepTimer(sleepTimer),
|
|
||||||
style: TextStyle(
|
|
||||||
color: isActive ? Colors.amber : Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => _navigateTo(_SettingsView.sleep),
|
onTap: () => _navigateTo(_SettingsView.sleep),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
||||||
// Audio Sync
|
// Audio Sync
|
||||||
ListTile(
|
_SettingsMenuItem(
|
||||||
leading: Icon(
|
icon: Icons.sync,
|
||||||
Icons.sync,
|
title: 'Audio Sync',
|
||||||
color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70,
|
valueText: _formatAudioSync(_audioSyncOffset),
|
||||||
),
|
isHighlighted: _audioSyncOffset != 0,
|
||||||
title: const Text(
|
|
||||||
'Audio Sync',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatAudioSync(_audioSyncOffset),
|
|
||||||
style: TextStyle(
|
|
||||||
color: _audioSyncOffset != 0 ? Colors.amber : Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => _navigateTo(_SettingsView.audioSync),
|
onTap: () => _navigateTo(_SettingsView.audioSync),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Subtitle Sync
|
// Subtitle Sync
|
||||||
ListTile(
|
_SettingsMenuItem(
|
||||||
leading: Icon(
|
icon: Icons.subtitles,
|
||||||
Icons.subtitles,
|
title: 'Subtitle Sync',
|
||||||
color: _subtitleSyncOffset != 0 ? Colors.amber : Colors.white70,
|
valueText: _formatAudioSync(_subtitleSyncOffset),
|
||||||
),
|
isHighlighted: _subtitleSyncOffset != 0,
|
||||||
title: const Text(
|
|
||||||
'Subtitle Sync',
|
|
||||||
style: TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
_formatAudioSync(_subtitleSyncOffset),
|
|
||||||
style: TextStyle(
|
|
||||||
color: _subtitleSyncOffset != 0
|
|
||||||
? Colors.amber
|
|
||||||
: Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => _navigateTo(_SettingsView.subtitleSync),
|
onTap: () => _navigateTo(_SettingsView.subtitleSync),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -327,29 +300,11 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
? currentDevice.name
|
? currentDevice.name
|
||||||
: currentDevice.description;
|
: currentDevice.description;
|
||||||
|
|
||||||
return ListTile(
|
return _SettingsMenuItem(
|
||||||
leading: const Icon(Icons.speaker, color: Colors.white70),
|
icon: Icons.speaker,
|
||||||
title: const Text(
|
title: 'Audio Output',
|
||||||
'Audio Output',
|
valueText: deviceLabel,
|
||||||
style: TextStyle(color: Colors.white),
|
allowValueOverflow: true,
|
||||||
),
|
|
||||||
trailing: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: Text(
|
|
||||||
deviceLabel,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Icon(Icons.chevron_right, color: Colors.white70),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
onTap: () => _navigateTo(_SettingsView.audioDevice),
|
onTap: () => _navigateTo(_SettingsView.audioDevice),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -399,119 +354,10 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
|||||||
Widget _buildSleepView() {
|
Widget _buildSleepView() {
|
||||||
final sleepTimer = SleepTimerService();
|
final sleepTimer = SleepTimerService();
|
||||||
|
|
||||||
return ListenableBuilder(
|
return SleepTimerContent(
|
||||||
listenable: sleepTimer,
|
player: widget.player,
|
||||||
builder: (context, _) {
|
sleepTimer: sleepTimer,
|
||||||
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
onCancel: () => Navigator.pop(context),
|
||||||
final remainingTime = sleepTimer.remainingTime;
|
|
||||||
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// Active timer status
|
|
||||||
if (sleepTimer.isActive && remainingTime != null) ...[
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
color: Colors.amber.withValues(alpha: 0.1),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'Timer Active',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.amber,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
'Playback will pause in ${_formatSleepTimerDuration(remainingTime)}',
|
|
||||||
style: const TextStyle(
|
|
||||||
color: Colors.white70,
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
OutlinedButton.icon(
|
|
||||||
icon: const Icon(Icons.add),
|
|
||||||
label: Text(
|
|
||||||
t.videoControls.addTime(amount: "15", unit: " min"),
|
|
||||||
),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
side: const BorderSide(color: Colors.white54),
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
sleepTimer.extendTimer(const Duration(minutes: 15));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
FilledButton.icon(
|
|
||||||
icon: const Icon(Icons.cancel),
|
|
||||||
label: Text(t.common.cancel),
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.red,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
sleepTimer.cancelTimer();
|
|
||||||
Navigator.pop(context); // Close after cancel
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(color: Colors.white24, height: 1),
|
|
||||||
],
|
|
||||||
|
|
||||||
// Duration list
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: durations.length,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
final minutes = durations[index];
|
|
||||||
final label = minutes < 60
|
|
||||||
? '$minutes minutes'
|
|
||||||
: '${(minutes / 60).toStringAsFixed(minutes % 60 == 0 ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
|
|
||||||
|
|
||||||
return ListTile(
|
|
||||||
leading: const Icon(Icons.timer, color: Colors.white70),
|
|
||||||
title: Text(
|
|
||||||
label,
|
|
||||||
style: const TextStyle(color: Colors.white),
|
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
|
||||||
widget.player.pause();
|
|
||||||
if (context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(
|
|
||||||
content: Text(
|
|
||||||
'Sleep timer completed - playback paused',
|
|
||||||
),
|
|
||||||
duration: Duration(seconds: 3),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
Navigator.pop(context); // Close after selection
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
SnackBar(
|
|
||||||
content: Text(t.messages.sleepTimerSet(label: label)),
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../../../services/sleep_timer_service.dart';
|
||||||
|
import '../../../i18n/strings.g.dart';
|
||||||
|
|
||||||
|
/// Widget displaying active sleep timer status with extend/cancel actions
|
||||||
|
class SleepTimerActiveStatus extends StatelessWidget {
|
||||||
|
final SleepTimerService sleepTimer;
|
||||||
|
final Duration remainingTime;
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
const SleepTimerActiveStatus({
|
||||||
|
super.key,
|
||||||
|
required this.sleepTimer,
|
||||||
|
required this.remainingTime,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final hours = duration.inHours;
|
||||||
|
final minutes = duration.inMinutes.remainder(60);
|
||||||
|
final seconds = duration.inSeconds.remainder(60);
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return '${hours}h ${minutes}m ${seconds}s';
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return '${minutes}m ${seconds}s';
|
||||||
|
} else {
|
||||||
|
return '${seconds}s';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
color: Colors.amber.withValues(alpha: 0.1),
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'Timer Active',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.amber,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Playback will pause in ${_formatDuration(remainingTime)}',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
OutlinedButton.icon(
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: Text(
|
||||||
|
t.videoControls.addTime(
|
||||||
|
amount: "15",
|
||||||
|
unit: " min",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.white54),
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
sleepTimer.extendTimer(
|
||||||
|
const Duration(minutes: 15),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
FilledButton.icon(
|
||||||
|
icon: const Icon(Icons.cancel),
|
||||||
|
label: Text(t.common.cancel),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
sleepTimer.cancelTimer();
|
||||||
|
onCancel?.call();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import '../../../services/sleep_timer_service.dart';
|
||||||
|
import 'sleep_timer_active_status.dart';
|
||||||
|
import 'sleep_timer_duration_list.dart';
|
||||||
|
|
||||||
|
/// Shared UI for sleep timer selection and active status.
|
||||||
|
class SleepTimerContent extends StatelessWidget {
|
||||||
|
final Player player;
|
||||||
|
final SleepTimerService sleepTimer;
|
||||||
|
final int? defaultDuration;
|
||||||
|
final VoidCallback? onCancel;
|
||||||
|
|
||||||
|
const SleepTimerContent({
|
||||||
|
super.key,
|
||||||
|
required this.player,
|
||||||
|
required this.sleepTimer,
|
||||||
|
this.defaultDuration,
|
||||||
|
this.onCancel,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: sleepTimer,
|
||||||
|
builder: (context, _) {
|
||||||
|
final remainingTime = sleepTimer.remainingTime;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
if (sleepTimer.isActive && remainingTime != null) ...[
|
||||||
|
SleepTimerActiveStatus(
|
||||||
|
sleepTimer: sleepTimer,
|
||||||
|
remainingTime: remainingTime,
|
||||||
|
onCancel: onCancel,
|
||||||
|
),
|
||||||
|
const Divider(color: Colors.white24, height: 1),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: SleepTimerDurationList(
|
||||||
|
player: player,
|
||||||
|
sleepTimer: sleepTimer,
|
||||||
|
defaultDuration: defaultDuration,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:media_kit/media_kit.dart';
|
||||||
|
import '../../../services/sleep_timer_service.dart';
|
||||||
|
import '../../../i18n/strings.g.dart';
|
||||||
|
|
||||||
|
/// Widget displaying list of sleep timer durations for selection
|
||||||
|
class SleepTimerDurationList extends StatelessWidget {
|
||||||
|
final Player player;
|
||||||
|
final SleepTimerService sleepTimer;
|
||||||
|
final int? defaultDuration;
|
||||||
|
|
||||||
|
const SleepTimerDurationList({
|
||||||
|
super.key,
|
||||||
|
required this.player,
|
||||||
|
required this.sleepTimer,
|
||||||
|
this.defaultDuration,
|
||||||
|
});
|
||||||
|
|
||||||
|
String _formatLabel(int minutes) {
|
||||||
|
if (minutes < 60) {
|
||||||
|
return '$minutes minutes';
|
||||||
|
}
|
||||||
|
final hours = minutes / 60;
|
||||||
|
final isWholeHour = minutes % 60 == 0;
|
||||||
|
return '${hours.toStringAsFixed(isWholeHour ? 0 : 1)} ${minutes == 60 ? 'hour' : 'hours'}';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final durations = [5, 10, 15, 30, 45, 60, 90, 120];
|
||||||
|
// Add default duration if provided and not already in list
|
||||||
|
if (defaultDuration != null && !durations.contains(defaultDuration)) {
|
||||||
|
durations.add(defaultDuration!);
|
||||||
|
durations.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: durations.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final minutes = durations[index];
|
||||||
|
final label = _formatLabel(minutes);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(Icons.timer, color: Colors.white70),
|
||||||
|
title: Text(
|
||||||
|
label,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
sleepTimer.startTimer(Duration(minutes: minutes), () {
|
||||||
|
// Pause playback when timer completes
|
||||||
|
player.pause();
|
||||||
|
|
||||||
|
// Show a snackbar notification
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Sleep timer completed - playback paused',
|
||||||
|
),
|
||||||
|
duration: Duration(seconds: 3),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Navigator.pop(context);
|
||||||
|
|
||||||
|
// Show confirmation snackbar
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
t.messages.sleepTimerSet(label: label),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user