feat: sidenav on desktop

This commit is contained in:
edde746
2025-12-06 12:43:31 +01:00
parent 7b0aa12c38
commit a373477105
15 changed files with 826 additions and 311 deletions
+7
View File
@@ -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.
+6
View File
@@ -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());
+147 -277
View File
@@ -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: [
+48 -1
View File
@@ -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(
+38
View File
@@ -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;
}
+53 -1
View File
@@ -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, _) {
+12
View File
@@ -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) {
+5 -1
View File
@@ -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,
+3 -2
View File
@@ -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
View File
@@ -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,
),
),
],
],
+452
View File
@@ -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"))
+6
View File
@@ -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
+24
View File
@@ -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:
+1
View File
@@ -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