feat: sidenav on desktop
This commit is contained in:
@@ -13,6 +13,11 @@
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||
|
||||
<!-- Android TV support (not required, but allow detection) -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
|
||||
<application
|
||||
android:label="Plezy"
|
||||
android:name="${applicationName}"
|
||||
@@ -37,6 +42,8 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<!-- Allow app to appear in Android TV launcher -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- Don't delete the meta-data below.
|
||||
|
||||
@@ -10,6 +10,7 @@ import 'services/macos_titlebar_service.dart';
|
||||
import 'services/fullscreen_state_manager.dart';
|
||||
import 'services/update_service.dart';
|
||||
import 'services/settings_service.dart';
|
||||
import 'services/tv_detection_service.dart';
|
||||
import 'providers/user_profile_provider.dart';
|
||||
import 'providers/plex_client_provider.dart';
|
||||
import 'providers/multi_server_provider.dart';
|
||||
@@ -47,6 +48,11 @@ void main() async {
|
||||
futures.add(windowManager.ensureInitialized());
|
||||
}
|
||||
|
||||
// Initialize TV detection for Android
|
||||
if (Platform.isAndroid) {
|
||||
futures.add(TvDetectionService.getInstance().then((_) {}));
|
||||
}
|
||||
|
||||
// Configure macOS window with custom titlebar (depends on window manager)
|
||||
futures.add(MacOSTitlebarService.setupCustomTitlebar());
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ import '../../models/plex_sort.dart';
|
||||
import '../../providers/hidden_libraries_provider.dart';
|
||||
import '../../providers/multi_server_provider.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/provider_extensions.dart';
|
||||
import '../../widgets/desktop_app_bar.dart';
|
||||
import 'context_menu_wrapper.dart';
|
||||
import 'server_badge.dart';
|
||||
import '../../services/storage_service.dart';
|
||||
import '../../mixins/refreshable.dart';
|
||||
import '../../mixins/item_updatable.dart';
|
||||
@@ -24,7 +24,9 @@ import 'tabs/library_collections_tab.dart';
|
||||
import 'tabs/library_playlists_tab.dart';
|
||||
|
||||
class LibrariesScreen extends StatefulWidget {
|
||||
const LibrariesScreen({super.key});
|
||||
final VoidCallback? onLibraryOrderChanged;
|
||||
|
||||
const LibrariesScreen({super.key, this.onLibraryOrderChanged});
|
||||
|
||||
@override
|
||||
State<LibrariesScreen> createState() => _LibrariesScreenState();
|
||||
@@ -291,6 +293,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
final storage = await StorageService.getInstance();
|
||||
final libraryKeys = _allLibraries.map((lib) => lib.globalKey).toList();
|
||||
await storage.saveLibraryOrder(libraryKeys);
|
||||
widget.onLibraryOrderChanged?.call();
|
||||
}
|
||||
|
||||
/// Public method to load a library by key (called from MainScreen side nav)
|
||||
void loadLibraryByKey(String libraryGlobalKey) {
|
||||
_loadLibraryContent(libraryGlobalKey);
|
||||
}
|
||||
|
||||
Future<void> _loadLibraryContent(String libraryGlobalKey) async {
|
||||
@@ -753,113 +761,75 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build grouped dropdown menu items with server sections
|
||||
/// Get set of library names that appear more than once (not globally unique)
|
||||
Set<String> _getNonUniqueLibraryNames(List<PlexLibrary> libraries) {
|
||||
final nameCounts = <String, int>{};
|
||||
for (final lib in libraries) {
|
||||
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
||||
}
|
||||
return nameCounts.entries
|
||||
.where((e) => e.value > 1)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
/// Build dropdown menu items with server subtitle for non-unique names
|
||||
List<PopupMenuEntry<String>> _buildGroupedLibraryMenuItems(
|
||||
List<PlexLibrary> visibleLibraries,
|
||||
) {
|
||||
final List<PopupMenuEntry<String>> menuItems = [];
|
||||
// Find which library names are not unique
|
||||
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
|
||||
|
||||
if (!_hasMultipleServers) {
|
||||
// Single server: flat list
|
||||
return visibleLibraries.map((library) {
|
||||
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
||||
return PopupMenuItem<String>(
|
||||
value: library.globalKey,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
library.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList();
|
||||
}
|
||||
return visibleLibraries.map((library) {
|
||||
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
||||
final showServerName = nonUniqueNames.contains(library.title) &&
|
||||
library.serverName != null;
|
||||
|
||||
// Multiple servers: group by server
|
||||
final Map<String, List<PlexLibrary>> groupedLibraries = {};
|
||||
for (final library in visibleLibraries) {
|
||||
final serverKey = library.serverId ?? 'unknown';
|
||||
groupedLibraries.putIfAbsent(serverKey, () => []).add(library);
|
||||
}
|
||||
|
||||
// Use ordered server keys
|
||||
final serverKeys = _getOrderedServerIds(visibleLibraries);
|
||||
for (int i = 0; i < serverKeys.length; i++) {
|
||||
final serverKey = serverKeys[i];
|
||||
final libraries = groupedLibraries[serverKey]!;
|
||||
final serverName = libraries.first.serverName ?? 'Unknown Server';
|
||||
|
||||
// Add server header
|
||||
menuItems.add(
|
||||
PopupMenuItem<String>(
|
||||
enabled: false,
|
||||
height: 24,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
child: Text(
|
||||
serverName,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
return PopupMenuItem<String>(
|
||||
value: library.globalKey,
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
library.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
if (showServerName)
|
||||
Text(
|
||||
library.serverName!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// Add libraries for this server
|
||||
for (final library in libraries) {
|
||||
final isSelected = library.globalKey == _selectedLibraryGlobalKey;
|
||||
menuItems.add(
|
||||
PopupMenuItem<String>(
|
||||
value: library.globalKey,
|
||||
child: Row(
|
||||
children: [
|
||||
const SizedBox(width: 12), // Indent library items
|
||||
Icon(
|
||||
_getLibraryIcon(library.type),
|
||||
size: 20,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
library.title,
|
||||
style: TextStyle(
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Add divider between server groups (except after last)
|
||||
if (i < serverKeys.length - 1) {
|
||||
menuItems.add(const PopupMenuDivider());
|
||||
}
|
||||
}
|
||||
|
||||
return menuItems;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
Widget _buildTabChip(String label, int index) {
|
||||
@@ -887,6 +857,33 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the app bar title - either dropdown on mobile or simple title on desktop
|
||||
Widget _buildAppBarTitle(List<PlexLibrary> visibleLibraries) {
|
||||
// No libraries or no selection
|
||||
if (visibleLibraries.isEmpty || _selectedLibraryGlobalKey == null) {
|
||||
return Text(t.libraries.title);
|
||||
}
|
||||
|
||||
// On desktop/TV with side nav, show tabs in app bar (library name is in side nav)
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_buildTabChip(t.libraries.tabs.recommended, 0),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabChip(t.libraries.tabs.browse, 1),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabChip(t.libraries.tabs.collections, 2),
|
||||
const SizedBox(width: 8),
|
||||
_buildTabChip(t.libraries.tabs.playlists, 3),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// On mobile, show the dropdown
|
||||
return _buildLibraryDropdownTitle(visibleLibraries);
|
||||
}
|
||||
|
||||
Widget _buildLibraryDropdownTitle(List<PlexLibrary> visibleLibraries) {
|
||||
final selectedLibrary = visibleLibraries.firstWhere(
|
||||
(lib) => lib.globalKey == _selectedLibraryGlobalKey,
|
||||
@@ -955,10 +952,7 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
DesktopSliverAppBar(
|
||||
title:
|
||||
visibleLibraries.isNotEmpty && _selectedLibraryGlobalKey != null
|
||||
? _buildLibraryDropdownTitle(visibleLibraries)
|
||||
: Text(t.libraries.title),
|
||||
title: _buildAppBarTitle(visibleLibraries),
|
||||
floating: true,
|
||||
pinned: true,
|
||||
backgroundColor: Theme.of(context).scaffoldBackgroundColor,
|
||||
@@ -1023,8 +1017,9 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
// Tab selector chips
|
||||
if (_selectedLibraryGlobalKey != null)
|
||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
||||
if (_selectedLibraryGlobalKey != null &&
|
||||
!PlatformDetector.shouldUseSideNavigation(context))
|
||||
SliverToBoxAdapter(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
@@ -1127,77 +1122,11 @@ class _LibraryManagementSheet extends StatefulWidget {
|
||||
|
||||
class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
late List<PlexLibrary> _tempLibraries;
|
||||
List<String>? _serverOrder;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tempLibraries = List.from(widget.allLibraries);
|
||||
_loadServerOrder();
|
||||
}
|
||||
|
||||
/// Load server order from storage
|
||||
Future<void> _loadServerOrder() async {
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedOrder = storage.getServerOrder();
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverOrder = savedOrder;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Save server order to storage
|
||||
Future<void> _saveServerOrder(List<String> serverIds) async {
|
||||
final storage = await StorageService.getInstance();
|
||||
await storage.saveServerOrder(serverIds);
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_serverOrder = serverIds;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Get ordered list of server IDs
|
||||
List<String> _getOrderedServerIds() {
|
||||
// Get unique server IDs from libraries
|
||||
final serverIds = _tempLibraries
|
||||
.where((lib) => lib.serverId != null)
|
||||
.map((lib) => lib.serverId!)
|
||||
.toSet()
|
||||
.toList();
|
||||
|
||||
if (_serverOrder == null || _serverOrder!.isEmpty) {
|
||||
return serverIds;
|
||||
}
|
||||
|
||||
// Apply saved order, but include any new servers not in the saved order
|
||||
final ordered = <String>[];
|
||||
for (final id in _serverOrder!) {
|
||||
if (serverIds.contains(id)) {
|
||||
ordered.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Add any servers not in saved order
|
||||
for (final id in serverIds) {
|
||||
if (!ordered.contains(id)) {
|
||||
ordered.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return ordered;
|
||||
}
|
||||
|
||||
/// Check if libraries come from multiple servers
|
||||
bool get _hasMultipleServers {
|
||||
final uniqueServerIds = _tempLibraries
|
||||
.where((lib) => lib.serverId != null)
|
||||
.map((lib) => lib.serverId)
|
||||
.toSet();
|
||||
return uniqueServerIds.length > 1;
|
||||
}
|
||||
|
||||
void _reorderLibraries(int oldIndex, int newIndex) {
|
||||
@@ -1301,6 +1230,18 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get set of library names that appear more than once (not globally unique)
|
||||
Set<String> _getNonUniqueLibraryNames() {
|
||||
final nameCounts = <String, int>{};
|
||||
for (final lib in _tempLibraries) {
|
||||
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
||||
}
|
||||
return nameCounts.entries
|
||||
.where((e) => e.value > 1)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Watch provider to rebuild when hidden libraries change
|
||||
@@ -1346,12 +1287,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
|
||||
// Library list (grouped by server if multiple servers)
|
||||
Expanded(
|
||||
child: _hasMultipleServers
|
||||
? _buildGroupedLibraryList(
|
||||
scrollController,
|
||||
hiddenLibraryKeys,
|
||||
)
|
||||
: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
||||
child: _buildFlatLibraryList(scrollController, hiddenLibraryKeys),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -1359,11 +1295,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build flat library list (single server)
|
||||
/// Build flat library list with server subtitle for non-unique names
|
||||
Widget _buildFlatLibraryList(
|
||||
ScrollController scrollController,
|
||||
Set<String> hiddenLibraryKeys,
|
||||
) {
|
||||
final nonUniqueNames = _getNonUniqueLibraryNames();
|
||||
|
||||
return ReorderableListView.builder(
|
||||
scrollController: scrollController,
|
||||
onReorder: _reorderLibraries,
|
||||
@@ -1372,83 +1310,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
buildDefaultDragHandles: false,
|
||||
itemBuilder: (context, index) {
|
||||
final library = _tempLibraries[index];
|
||||
return _buildLibraryTile(library, index, hiddenLibraryKeys);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Build grouped library list (multiple servers)
|
||||
Widget _buildGroupedLibraryList(
|
||||
ScrollController scrollController,
|
||||
Set<String> hiddenLibraryKeys,
|
||||
) {
|
||||
// Group libraries by server
|
||||
final Map<String, List<PlexLibrary>> groupedLibraries = {};
|
||||
for (final library in _tempLibraries) {
|
||||
final serverKey = library.serverId ?? 'unknown';
|
||||
groupedLibraries.putIfAbsent(serverKey, () => []).add(library);
|
||||
}
|
||||
|
||||
// Use ordered server keys
|
||||
final serverKeys = _getOrderedServerIds();
|
||||
|
||||
return ReorderableListView.builder(
|
||||
scrollController: scrollController,
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
buildDefaultDragHandles: false,
|
||||
onReorder: (oldIndex, newIndex) {
|
||||
// Reorder servers
|
||||
final reorderedServerIds = List<String>.from(serverKeys);
|
||||
if (newIndex > oldIndex) {
|
||||
newIndex -= 1;
|
||||
}
|
||||
final serverId = reorderedServerIds.removeAt(oldIndex);
|
||||
reorderedServerIds.insert(newIndex, serverId);
|
||||
_saveServerOrder(reorderedServerIds);
|
||||
},
|
||||
itemCount: serverKeys.length,
|
||||
itemBuilder: (context, serverIndex) {
|
||||
final serverKey = serverKeys[serverIndex];
|
||||
final libraries = groupedLibraries[serverKey]!;
|
||||
final serverName = libraries.first.serverName ?? 'Unknown Server';
|
||||
|
||||
return Column(
|
||||
key: ValueKey(serverKey),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Server header with drag handle
|
||||
ListTile(
|
||||
leading: ReorderableDragStartListener(
|
||||
index: serverIndex,
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: IconTheme.of(context).color?.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
serverName,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Libraries for this server (not reorderable)
|
||||
...libraries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final library = entry.value;
|
||||
return _buildLibraryTile(
|
||||
library,
|
||||
index,
|
||||
hiddenLibraryKeys,
|
||||
showServerBadge: false,
|
||||
enableDrag: false,
|
||||
);
|
||||
}),
|
||||
],
|
||||
final showServerName = nonUniqueNames.contains(library.title) &&
|
||||
library.serverName != null;
|
||||
return _buildLibraryTile(
|
||||
library,
|
||||
index,
|
||||
hiddenLibraryKeys,
|
||||
showServerName: showServerName,
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -1459,8 +1327,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
PlexLibrary library,
|
||||
int index,
|
||||
Set<String> hiddenLibraryKeys, {
|
||||
bool showServerBadge = true,
|
||||
bool enableDrag = true,
|
||||
bool showServerName = false,
|
||||
}) {
|
||||
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
||||
|
||||
@@ -1471,28 +1338,31 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> {
|
||||
leading: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (enableDrag)
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: IconTheme.of(context).color?.withValues(alpha: 0.5),
|
||||
),
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Icon(
|
||||
Icons.drag_indicator,
|
||||
color: IconTheme.of(context).color?.withValues(alpha: 0.5),
|
||||
),
|
||||
if (enableDrag) const SizedBox(width: 8),
|
||||
if (!enableDrag) const SizedBox(width: 12),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(_getLibraryIcon(library.type)),
|
||||
],
|
||||
),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(child: Text(library.title)),
|
||||
if (showServerBadge &&
|
||||
_hasMultipleServers &&
|
||||
library.serverName != null)
|
||||
ServerBadge(serverName: library.serverName, showFullName: true),
|
||||
],
|
||||
),
|
||||
title: Text(library.title),
|
||||
subtitle: showServerName
|
||||
? Text(
|
||||
library.serverName!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.bodySmall
|
||||
?.color
|
||||
?.withValues(alpha: 0.6),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../services/plex_client.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../main.dart';
|
||||
import '../mixins/refreshable.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
@@ -12,6 +13,8 @@ import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/playback_state_provider.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../utils/desktop_window_padding.dart';
|
||||
import '../widgets/side_navigation_rail.dart';
|
||||
import 'discover_screen.dart';
|
||||
import 'libraries/libraries_screen.dart';
|
||||
import 'search_screen.dart';
|
||||
@@ -28,12 +31,14 @@ class MainScreen extends StatefulWidget {
|
||||
|
||||
class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
int _currentIndex = 0;
|
||||
String? _selectedLibraryGlobalKey;
|
||||
|
||||
late final List<Widget> _screens;
|
||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
||||
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
|
||||
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -44,7 +49,10 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
key: _discoverKey,
|
||||
onBecameVisible: _onDiscoverBecameVisible,
|
||||
),
|
||||
LibrariesScreen(key: _librariesKey),
|
||||
LibrariesScreen(
|
||||
key: _librariesKey,
|
||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||
),
|
||||
SearchScreen(key: _searchKey),
|
||||
SettingsScreen(key: _settingsKey),
|
||||
];
|
||||
@@ -97,6 +105,11 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
void _onLibraryOrderChanged() {
|
||||
// Refresh side navigation when library order changes
|
||||
_sideNavKey.currentState?.reloadLibraries();
|
||||
}
|
||||
|
||||
/// Invalidate all cached data across all screens when profile is switched
|
||||
/// Receives the list of servers with new profile tokens for reconnection
|
||||
Future<void> _invalidateAllScreens(List<PlexServer> servers) async {
|
||||
@@ -167,8 +180,42 @@ class _MainScreenState extends State<MainScreen> with RouteAware {
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle library selection from side navigation rail
|
||||
void _selectLibrary(String libraryGlobalKey) {
|
||||
setState(() {
|
||||
_selectedLibraryGlobalKey = libraryGlobalKey;
|
||||
_currentIndex = 1; // Switch to Libraries tab
|
||||
});
|
||||
// Tell LibrariesScreen to load this library
|
||||
final librariesState = _librariesKey.currentState;
|
||||
if (librariesState != null) {
|
||||
(librariesState as dynamic).loadLibraryByKey(libraryGlobalKey);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final useSideNav = PlatformDetector.shouldUseSideNavigation(context);
|
||||
|
||||
if (useSideNav) {
|
||||
return SideNavigationScope(
|
||||
child: Row(
|
||||
children: [
|
||||
SideNavigationRail(
|
||||
key: _sideNavKey,
|
||||
selectedIndex: _currentIndex,
|
||||
selectedLibraryKey: _selectedLibraryGlobalKey,
|
||||
onDestinationSelected: _selectTab,
|
||||
onLibrarySelected: _selectLibrary,
|
||||
),
|
||||
Expanded(
|
||||
child: IndexedStack(index: _currentIndex, children: _screens),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: IndexedStack(index: _currentIndex, children: _screens),
|
||||
bottomNavigationBar: NavigationBar(
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
||||
/// Service for detecting if the app is running on Android TV
|
||||
class TvDetectionService {
|
||||
static TvDetectionService? _instance;
|
||||
bool _isTV = false;
|
||||
bool _initialized = false;
|
||||
|
||||
TvDetectionService._();
|
||||
|
||||
/// Get the singleton instance, initializing if needed
|
||||
static Future<TvDetectionService> getInstance() async {
|
||||
if (_instance == null) {
|
||||
_instance = TvDetectionService._();
|
||||
await _instance!._detect();
|
||||
}
|
||||
return _instance!;
|
||||
}
|
||||
|
||||
Future<void> _detect() async {
|
||||
if (_initialized) return;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
final deviceInfo = DeviceInfoPlugin();
|
||||
final androidInfo = await deviceInfo.androidInfo;
|
||||
// Check for android.software.leanback feature (standard Android TV detection)
|
||||
_isTV = androidInfo.systemFeatures.contains('android.software.leanback');
|
||||
}
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
bool get isTV => _isTV;
|
||||
|
||||
/// Synchronous access after initialization (returns false if not initialized)
|
||||
static bool isTVSync() => _instance?._isTV ?? false;
|
||||
}
|
||||
@@ -2,6 +2,23 @@ import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
|
||||
/// InheritedWidget to indicate that a side navigation is present in the widget tree.
|
||||
/// When present, app bars should skip their left padding since the side nav
|
||||
/// already handles the macOS traffic lights area.
|
||||
class SideNavigationScope extends InheritedWidget {
|
||||
const SideNavigationScope({
|
||||
super.key,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static bool isPresent(BuildContext context) {
|
||||
return context.dependOnInheritedWidgetOfExactType<SideNavigationScope>() != null;
|
||||
}
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(SideNavigationScope oldWidget) => false;
|
||||
}
|
||||
|
||||
/// Padding values for desktop window controls
|
||||
class DesktopWindowPadding {
|
||||
/// Left padding for macOS traffic lights (normal window mode)
|
||||
@@ -45,14 +62,28 @@ class DesktopAppBarHelper {
|
||||
/// Builds leading widget with appropriate left padding for macOS traffic lights
|
||||
///
|
||||
/// [includeGestureDetector] - If true, wraps in GestureDetector to prevent window dragging
|
||||
/// [context] - Required to check if side navigation is visible
|
||||
static Widget? buildAdjustedLeading(
|
||||
Widget? leading, {
|
||||
bool includeGestureDetector = false,
|
||||
BuildContext? context,
|
||||
}) {
|
||||
if (!Platform.isMacOS || leading == null) {
|
||||
return leading;
|
||||
}
|
||||
|
||||
// Skip left padding when side navigation scope is present in widget tree
|
||||
if (context != null && SideNavigationScope.isPresent(context)) {
|
||||
if (includeGestureDetector) {
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onPanDown: (_) {},
|
||||
child: leading,
|
||||
);
|
||||
}
|
||||
return leading;
|
||||
}
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
@@ -94,11 +125,17 @@ class DesktopAppBarHelper {
|
||||
}
|
||||
|
||||
/// Calculates the leading width for SliverAppBar to account for macOS traffic lights
|
||||
static double? calculateLeadingWidth(Widget? leading) {
|
||||
/// [context] - Required to check if side navigation is visible
|
||||
static double? calculateLeadingWidth(Widget? leading, {BuildContext? context}) {
|
||||
if (!Platform.isMacOS || leading == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Skip extra width when side navigation scope is present in widget tree
|
||||
if (context != null && SideNavigationScope.isPresent(context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
final leftPadding = isFullscreen
|
||||
? DesktopWindowPadding.macOSLeftFullscreen
|
||||
@@ -125,6 +162,8 @@ class DesktopAppBarHelper {
|
||||
|
||||
/// A widget that adds padding to account for desktop window controls.
|
||||
/// On macOS, adds left padding for traffic lights (reduced in fullscreen).
|
||||
/// When side navigation is visible, left padding is skipped as the side nav
|
||||
/// already occupies the traffic lights area.
|
||||
class DesktopTitleBarPadding extends StatelessWidget {
|
||||
final Widget child;
|
||||
final double? leftPadding;
|
||||
@@ -143,6 +182,19 @@ class DesktopTitleBarPadding extends StatelessWidget {
|
||||
return child;
|
||||
}
|
||||
|
||||
// Skip left padding when side navigation scope is present in widget tree
|
||||
// (side nav already handles the traffic lights area)
|
||||
if (SideNavigationScope.isPresent(context)) {
|
||||
final right = rightPadding ?? 0.0;
|
||||
if (right == 0.0) {
|
||||
return child;
|
||||
}
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(right: right),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
return ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math';
|
||||
|
||||
import '../services/tv_detection_service.dart';
|
||||
|
||||
/// Utility class for platform detection
|
||||
class PlatformDetector {
|
||||
/// Detects if running on Android TV (requires TvDetectionService to be initialized)
|
||||
static bool isTV() {
|
||||
return TvDetectionService.isTVSync();
|
||||
}
|
||||
|
||||
/// Detects if the app should use side navigation (Desktop or TV)
|
||||
static bool shouldUseSideNavigation(BuildContext context) {
|
||||
return isDesktop(context) || isTV();
|
||||
}
|
||||
|
||||
/// Detects if running on a mobile platform (iOS or Android)
|
||||
/// Uses Theme for consistent platform detection across the app
|
||||
static bool isMobile(BuildContext context) {
|
||||
|
||||
@@ -70,8 +70,12 @@ class DesktopSliverAppBar extends StatelessWidget {
|
||||
leading: DesktopAppBarHelper.buildAdjustedLeading(
|
||||
effectiveLeading,
|
||||
includeGestureDetector: true,
|
||||
context: context,
|
||||
),
|
||||
leadingWidth: DesktopAppBarHelper.calculateLeadingWidth(
|
||||
effectiveLeading,
|
||||
context: context,
|
||||
),
|
||||
leadingWidth: DesktopAppBarHelper.calculateLeadingWidth(effectiveLeading),
|
||||
automaticallyImplyLeading:
|
||||
false, // Always false since we handle it manually
|
||||
elevation: elevation,
|
||||
|
||||
@@ -89,9 +89,10 @@ class HubSection extends StatelessWidget {
|
||||
final posterWidth = cardWidth - 16;
|
||||
// 2:3 poster aspect ratio (height is 1.5x width)
|
||||
final posterHeight = posterWidth * 1.5;
|
||||
// Container height = poster + padding + spacing + text
|
||||
// Container height = poster + padding + spacing + text + ListView padding
|
||||
// 8px top padding + posterHeight + 4px spacing + ~26px text + 8px bottom padding
|
||||
final containerHeight = posterHeight + 46;
|
||||
// + 10px for ListView vertical padding (5px top + 5px bottom)
|
||||
final containerHeight = posterHeight + 56;
|
||||
|
||||
return SizedBox(
|
||||
height: containerHeight,
|
||||
|
||||
+22
-29
@@ -159,9 +159,7 @@ class _MediaCardState extends State<MediaCard> {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SeasonDetailScreen(
|
||||
season: widget.item,
|
||||
),
|
||||
builder: (context) => SeasonDetailScreen(season: widget.item),
|
||||
),
|
||||
);
|
||||
// Season screen doesn't return a refresh flag, but we can refresh anyway
|
||||
@@ -289,9 +287,7 @@ class _MediaCardGrid extends StatelessWidget {
|
||||
if (playlist.leafCount != null &&
|
||||
playlist.leafCount! > 0) {
|
||||
return Text(
|
||||
t.playlists.itemCount(
|
||||
count: playlist.leafCount!,
|
||||
),
|
||||
t.playlists.itemCount(count: playlist.leafCount!),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
@@ -616,14 +612,13 @@ class _MediaCardList extends StatelessWidget {
|
||||
metadataLine,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.9),
|
||||
fontSize: _metadataFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.9),
|
||||
fontSize: _metadataFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
@@ -633,13 +628,12 @@ class _MediaCardList extends StatelessWidget {
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.85),
|
||||
fontSize: _subtitleFontSize,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
@@ -649,14 +643,13 @@ class _MediaCardList extends StatelessWidget {
|
||||
item.summary!,
|
||||
maxLines: _summaryMaxLines,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.7),
|
||||
fontSize: _summaryFontSize,
|
||||
height: 1.3,
|
||||
),
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: tokens(
|
||||
context,
|
||||
).textMuted.withValues(alpha: 0.7),
|
||||
fontSize: _summaryFontSize,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -0,0 +1,452 @@
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../models/plex_library.dart';
|
||||
import '../providers/hidden_libraries_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../services/fullscreen_state_manager.dart';
|
||||
import '../services/storage_service.dart';
|
||||
import '../theme/theme_helper.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
|
||||
/// Side navigation rail for Desktop and Android TV platforms
|
||||
class SideNavigationRail extends StatefulWidget {
|
||||
final int selectedIndex;
|
||||
final String? selectedLibraryKey;
|
||||
final ValueChanged<int> onDestinationSelected;
|
||||
final ValueChanged<String> onLibrarySelected;
|
||||
|
||||
const SideNavigationRail({
|
||||
super.key,
|
||||
required this.selectedIndex,
|
||||
this.selectedLibraryKey,
|
||||
required this.onDestinationSelected,
|
||||
required this.onLibrarySelected,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SideNavigationRail> createState() => SideNavigationRailState();
|
||||
}
|
||||
|
||||
class SideNavigationRailState extends State<SideNavigationRail> {
|
||||
bool _librariesExpanded = true;
|
||||
List<PlexLibrary> _libraries = [];
|
||||
bool _isLoadingLibraries = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
Future<void> _loadLibraries() async {
|
||||
final multiServerProvider = context.read<MultiServerProvider>();
|
||||
|
||||
if (!multiServerProvider.hasConnectedServers) {
|
||||
setState(() {
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final libraries = await multiServerProvider.aggregationService
|
||||
.getLibrariesFromAllServers();
|
||||
|
||||
// Filter out music libraries (not supported)
|
||||
var filteredLibraries = libraries
|
||||
.where((lib) => lib.type != 'artist')
|
||||
.toList();
|
||||
|
||||
// Apply saved library order
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedOrder = storage.getLibraryOrder();
|
||||
if (savedOrder != null && savedOrder.isNotEmpty) {
|
||||
final libraryMap = {
|
||||
for (var lib in filteredLibraries) lib.globalKey: lib,
|
||||
};
|
||||
final orderedLibraries = <PlexLibrary>[];
|
||||
for (final key in savedOrder) {
|
||||
if (libraryMap.containsKey(key)) {
|
||||
orderedLibraries.add(libraryMap[key]!);
|
||||
libraryMap.remove(key);
|
||||
}
|
||||
}
|
||||
// Add any new libraries not in saved order
|
||||
orderedLibraries.addAll(libraryMap.values);
|
||||
filteredLibraries = orderedLibraries;
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_libraries = filteredLibraries;
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoadingLibraries = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reload libraries (called when servers change)
|
||||
void reloadLibraries() {
|
||||
setState(() {
|
||||
_isLoadingLibraries = true;
|
||||
});
|
||||
_loadLibraries();
|
||||
}
|
||||
|
||||
IconData _getLibraryIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'movie':
|
||||
return Icons.movie_outlined;
|
||||
case 'show':
|
||||
return Icons.tv_outlined;
|
||||
case 'artist':
|
||||
return Icons.music_note_outlined;
|
||||
case 'photo':
|
||||
return Icons.photo_outlined;
|
||||
default:
|
||||
return Icons.folder_outlined;
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getLibraryIconFilled(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'movie':
|
||||
return Icons.movie;
|
||||
case 'show':
|
||||
return Icons.tv;
|
||||
case 'artist':
|
||||
return Icons.music_note;
|
||||
case 'photo':
|
||||
return Icons.photo;
|
||||
default:
|
||||
return Icons.folder;
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate top padding for macOS traffic lights
|
||||
double _getTopPadding(BuildContext context) {
|
||||
double basePadding = MediaQuery.of(context).padding.top + 16;
|
||||
|
||||
// On macOS, add extra padding for traffic lights (when not fullscreen)
|
||||
if (Platform.isMacOS) {
|
||||
final isFullscreen = FullscreenStateManager().isFullscreen;
|
||||
if (!isFullscreen) {
|
||||
// Traffic lights area is approximately 52 pixels high
|
||||
basePadding = basePadding < 52 ? 52 : basePadding;
|
||||
}
|
||||
}
|
||||
|
||||
return basePadding;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = tokens(context);
|
||||
final hiddenLibrariesProvider = context.watch<HiddenLibrariesProvider>();
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
|
||||
// Filter visible libraries
|
||||
final visibleLibraries = _libraries
|
||||
.where((lib) => !hiddenKeys.contains(lib.globalKey))
|
||||
.toList();
|
||||
|
||||
// Listen to fullscreen changes for macOS
|
||||
return ListenableBuilder(
|
||||
listenable: FullscreenStateManager(),
|
||||
builder: (context, _) {
|
||||
return Container(
|
||||
width: 220,
|
||||
color: t.surface,
|
||||
child: Column(
|
||||
children: [
|
||||
// Safe area for status bar and macOS traffic lights
|
||||
SizedBox(height: _getTopPadding(context)),
|
||||
|
||||
// Navigation content
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
children: [
|
||||
// Home
|
||||
_buildNavItem(
|
||||
icon: Icons.home_outlined,
|
||||
selectedIcon: Icons.home,
|
||||
label: Translations.of(context).navigation.home,
|
||||
isSelected: widget.selectedIndex == 0,
|
||||
onTap: () => widget.onDestinationSelected(0),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Libraries section
|
||||
_buildLibrariesSection(visibleLibraries, t),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Search
|
||||
_buildNavItem(
|
||||
icon: Icons.search,
|
||||
selectedIcon: Icons.search,
|
||||
label: Translations.of(context).navigation.search,
|
||||
isSelected: widget.selectedIndex == 2,
|
||||
onTap: () => widget.onDestinationSelected(2),
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Settings
|
||||
_buildNavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
selectedIcon: Icons.settings,
|
||||
label: Translations.of(context).navigation.settings,
|
||||
isSelected: widget.selectedIndex == 3,
|
||||
onTap: () => widget.onDestinationSelected(3),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNavItem({
|
||||
required IconData icon,
|
||||
required IconData selectedIcon,
|
||||
required String label,
|
||||
required bool isSelected,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
final t = tokens(context);
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? t.text.withValues(alpha: 0.1) : null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected ? selectedIcon : icon,
|
||||
size: 22,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLibrariesSection(List<PlexLibrary> visibleLibraries, dynamic t) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Libraries header with expand/collapse
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_librariesExpanded = !_librariesExpanded;
|
||||
});
|
||||
},
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
widget.selectedIndex == 1 &&
|
||||
widget.selectedLibraryKey == null
|
||||
? t.text.withValues(alpha: 0.1)
|
||||
: null,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
widget.selectedIndex == 1
|
||||
? Icons.video_library
|
||||
: Icons.video_library_outlined,
|
||||
size: 22,
|
||||
color: widget.selectedIndex == 1 ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
Translations.of(context).navigation.libraries,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: widget.selectedIndex == 1
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: widget.selectedIndex == 1 ? t.text : t.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
_librariesExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
size: 20,
|
||||
color: t.textMuted,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Library items
|
||||
if (_librariesExpanded)
|
||||
_isLoadingLibraries
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: t.textMuted,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: visibleLibraries.isEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Text(
|
||||
Translations.of(context).libraries.noLibrariesFound,
|
||||
style: TextStyle(fontSize: 12, color: t.textMuted),
|
||||
),
|
||||
)
|
||||
: _buildLibraryItems(visibleLibraries, t),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Get set of library names that appear more than once (not globally unique)
|
||||
Set<String> _getNonUniqueLibraryNames(List<PlexLibrary> libraries) {
|
||||
final nameCounts = <String, int>{};
|
||||
for (final lib in libraries) {
|
||||
nameCounts[lib.title] = (nameCounts[lib.title] ?? 0) + 1;
|
||||
}
|
||||
return nameCounts.entries
|
||||
.where((e) => e.value > 1)
|
||||
.map((e) => e.key)
|
||||
.toSet();
|
||||
}
|
||||
|
||||
Widget _buildLibraryItems(List<PlexLibrary> visibleLibraries, dynamic t) {
|
||||
// Find which library names are not unique
|
||||
final nonUniqueNames = _getNonUniqueLibraryNames(visibleLibraries);
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: visibleLibraries.map((library) {
|
||||
final showServerName =
|
||||
nonUniqueNames.contains(library.title) &&
|
||||
library.serverName != null;
|
||||
return _buildLibraryItem(library, t, showServerName: showServerName);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLibraryItem(
|
||||
PlexLibrary library,
|
||||
dynamic t, {
|
||||
bool showServerName = false,
|
||||
}) {
|
||||
final isSelected =
|
||||
widget.selectedIndex == 1 &&
|
||||
widget.selectedLibraryKey == library.globalKey;
|
||||
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => widget.onLibrarySelected(library.globalKey),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 28,
|
||||
right: 12,
|
||||
top: 10,
|
||||
bottom: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? t.text.withValues(alpha: 0.1) : null,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isSelected
|
||||
? _getLibraryIconFilled(library.type)
|
||||
: _getLibraryIcon(library.type),
|
||||
size: 18,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: SizedBox(
|
||||
height: 32, // Fixed height for consistent item sizing
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
library.title,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: isSelected
|
||||
? FontWeight.w600
|
||||
: FontWeight.w400,
|
||||
color: isSelected ? t.text : t.textMuted,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
if (showServerName)
|
||||
Text(
|
||||
library.serverName!,
|
||||
style: TextStyle(
|
||||
fontSize: 9,
|
||||
color: t.textMuted.withValues(alpha: 0.4),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import FlutterMacOS
|
||||
import Foundation
|
||||
|
||||
import connectivity_plus
|
||||
import device_info_plus
|
||||
import hotkey_manager_macos
|
||||
import macos_window_utils
|
||||
import os_media_controls
|
||||
@@ -19,6 +20,7 @@ import window_manager
|
||||
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
HotkeyManagerMacosPlugin.register(with: registry.registrar(forPlugin: "HotkeyManagerMacosPlugin"))
|
||||
MacOSWindowUtilsPlugin.register(with: registry.registrar(forPlugin: "MacOSWindowUtilsPlugin"))
|
||||
OsMediaControlsPlugin.register(with: registry.registrar(forPlugin: "OsMediaControlsPlugin"))
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
PODS:
|
||||
- connectivity_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- device_info_plus (0.0.1):
|
||||
- FlutterMacOS
|
||||
- FlutterMacOS (1.0.0)
|
||||
- HotKey (0.2.1)
|
||||
- hotkey_manager_macos (0.0.1):
|
||||
@@ -30,6 +32,7 @@ PODS:
|
||||
|
||||
DEPENDENCIES:
|
||||
- connectivity_plus (from `Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos`)
|
||||
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
|
||||
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||
- hotkey_manager_macos (from `Flutter/ephemeral/.symlinks/plugins/hotkey_manager_macos/macos`)
|
||||
- macos_window_utils (from `Flutter/ephemeral/.symlinks/plugins/macos_window_utils/macos`)
|
||||
@@ -49,6 +52,8 @@ SPEC REPOS:
|
||||
EXTERNAL SOURCES:
|
||||
connectivity_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/connectivity_plus/macos
|
||||
device_info_plus:
|
||||
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
|
||||
FlutterMacOS:
|
||||
:path: Flutter/ephemeral
|
||||
hotkey_manager_macos:
|
||||
@@ -74,6 +79,7 @@ EXTERNAL SOURCES:
|
||||
|
||||
SPEC CHECKSUMS:
|
||||
connectivity_plus: 4adf20a405e25b42b9c9f87feff8f4b6fde18a4e
|
||||
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
|
||||
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
|
||||
HotKey: 400beb7caa29054ea8d864c96f5ba7e5b4852277
|
||||
hotkey_manager_macos: a4317849af96d2430fa89944d3c58977ca089fbe
|
||||
|
||||
@@ -273,6 +273,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
device_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: device_info_plus
|
||||
sha256: "98f28b42168cc509abc92f88518882fd58061ea372d7999aecc424345c7bff6a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.5.0"
|
||||
device_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_plus_platform_interface
|
||||
sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.3"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -1254,6 +1270,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.15.0"
|
||||
win32_registry:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: win32_registry
|
||||
sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
window_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
||||
@@ -19,6 +19,7 @@ dependencies:
|
||||
window_manager: ^0.4.3
|
||||
logger: ^2.0.2
|
||||
package_info_plus: ^9.0.0
|
||||
device_info_plus: ^11.0.0
|
||||
provider: ^6.1.2
|
||||
hotkey_manager: ^0.2.3
|
||||
flex_color_picker: ^3.6.0
|
||||
|
||||
Reference in New Issue
Block a user