惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

P
Proofpoint News Feed
云风的 BLOG
云风的 BLOG
Apple Machine Learning Research
Apple Machine Learning Research
Hugging Face - Blog
Hugging Face - Blog
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
Google DeepMind News
Google DeepMind News
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
雷峰网
雷峰网
B
Blog
月光博客
月光博客
博客园 - 【当耐特】
WordPress大学
WordPress大学
Microsoft Azure Blog
Microsoft Azure Blog
I
InfoQ
The GitHub Blog
The GitHub Blog
Engineering at Meta
Engineering at Meta
Jina AI
Jina AI
博客园 - Franky
MyScale Blog
MyScale Blog
H
Hackread – Cybersecurity News, Data Breaches, AI and More
Last Week in AI
Last Week in AI
B
Blog RSS Feed
H
Help Net Security

DEV Community

Authentication Security Deep Dive: From Brute Force to Salted Hashing (With Java Examples) Why AI Systems Don’t Fail — They Drift Spilling beans for how i learn for exam😁"Reinforcement Learning Cheat Sheet" I Replaced Chrome with Safari for AI Browser Automation. Here's What Broke (and What Finally Worked) How Python Borrows Other People's Work The $40 Architecture: Processing 1 Billion API Requests with 99.99% Uptime Vibe Coding: A Workflow Guide (From Zero to SaaS) Most webhook security guides protect the wrong side. The scary part is delivery. Headless CMS for TanStack Start: Build a Blog with Cosmic EU Age Verification App "Hacked in 2 Minutes" — What Actually Happened Comfy Cloud’s delete function does not actually remove files Running AI Models on GPU Cloud Servers: A Beginner Guide Event-driven media intelligence with AWS Step Functions and Bedrock I scored 500 AI prompts across 8 quality dimensions — here's what broke How to Call Google Gemini API from Next.js (Free Tier, No Backend Needed) The Portal Protocol: Reclaiming Human Connection in the Age of AI How to Fix Your Team's Scattered Knowledge Problem With a Self-Hosted Forum Intro to tc Cloud Functors: A Graph-First Mental Model for the Modern Cloud Designing Multi-Tenant Backends With Both Ownership and Team Access I Built a Neumorphic CSS Library with 77+ Components — Here's What I Learned PostgreSQL Performance Optimization: Why Connection Pooling Is Critical at Scale Cómo construí un SaaS multi-rubro para gestionar expensas en Argentina con FastAPI + Vue 3 🚀 I Built an Ethical Hacking Scanner Tool – Open Source Project I Replaced /usage and /context in Claude Code With a Single Statusline A Pythonic Way to Handle Emails (IMAP/SMTP) with Auto-Discovery and AI-Ready Design I Collected 8.9 Million Polymarket Price Points — Here's What I Found About How Markets Really Move EcoTrack AI — Carbon Footprint Tracker & Dashboard Everyone's Using AI. No One Agrees How. 5 self-hosted ebook managers worth trying in 2026 Building Your First AI Agent with LangChain: From Chatbot to Autonomous Assistant
Flutter Desktop Mastery 2: System Tray & Local Notifications
Kingkor Roy · 2026-05-16 · via DEV Community

The system tray is essential for a lot of desktop apps that needs to run in the background. But due to Flutter’s limited desktop support, again the Flutter Desktop Community has saved us with another much needed plugin.

At Spotube, users expect to control playback from the tray, minimize the app while keeping it running, and have quick access to essential features without opening the main window.

So, we implement a reactive system tray that stays in sync with the app's state. We use the tray_manager package to accomplish that.

Introducing the Dependencies

In Spotube's pubspec.yaml, we include:

dependencies:
  tray_manager: <latest>
  local_notifier: <latest>
  # we use it for state management
  hooks_riverpod: <latest>
  window_manager: <latest>

Enter fullscreen mode Exit fullscreen mode

The tray_manager package provides cross-platform system tray integration, while local_notifier handles desktop notifications.

Building the Tray Menu

Our tray menu is built reactively using Riverpod providers.

In lib/provider/tray_manager/tray_menu.dart:

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';

final trayMenuProvider = Provider((ref) {
//..... internal logic
// ref.watch() to other providers that update this
// provider on change

// This is the part that you need to understand.
  return Menu(
    items: [
      MenuItem(
        label: "Show/Hide Window",
        onClick: (menuItem) async {
          // We use window_manager plugin to show or hide the window
          if (await windowManager.isVisible()) {
            await windowManager.hide();
          } else {
            await windowManager.focus();
            await windowManager.show();
          }
        },
      ),
      MenuItem.separator(), // A special type of menu item that creates a divider between other menu items
      // Play/Pause button that adapts to current state
      MenuItem(
        label: isPlaying ? "Pause" : "Play",
        // You can disable (gray out) an option in the menu
        disabled: !isPlaybackPlaying,
        onClick: (menuItem) async {
          // .. we are handling play/pause logic here
        },
      ),
      MenuItem(
        label: "Next",
        disabled: !isPlaybackPlaying,
        onClick: (menuItem) {
          // ... next handle logic
        },
      ),
      MenuItem(
        label: "Previous",
        disabled: !isPlaybackPlaying,
        onClick: (menuItem) {
          // ... previous handle logic
        },
      ),
      // Submenu for advanced playback controls
      MenuItem.submenu(
        label: "Playback",
        submenu: Menu(
          items: [
            // Repeat mode toggle
            MenuItem(
              label: "Repeat",
              // Checked is a special boolean that can be used
              // to show a check next to an item in the menu
              checked: isLoopOne,
              onClick: (menuItem) {
                // Logic for repeat
              },
            ),
            // Shuffle toggle
            MenuItem(
              label: "Shuffle",
              checked: isShuffled,
              onClick: (menuItem) {
                // Logic for shuffle
              },
            ),
            MenuItem.separator(),
            MenuItem(
              label: "Stop",
              onClick: (menuItem) {
                // Logic for stop
              },
            ),
          ],
        ),
      ),
      MenuItem.separator(),
      MenuItem(
        label: "Quit",
        onClick: (menuItem) {
          exit(0); // Closes the program
        },
      ),
    ],
  );
});

Enter fullscreen mode Exit fullscreen mode

Key features of our tray menu:

  • Reactive state: Menu items update when playback state changes
  • Context-aware labels: "Play" vs "Pause" adapts to current state
  • Disabled items: Playback controls are disabled when no track is loaded
  • Checked items: Repeat and Shuffle show their current state with checkmarks
  • Organized submenu: Advanced controls grouped under "Playback"

Platform-Aware Tray Manager

The system tray behaves differently on Windows vs macOS/Linux.

In lib/provider/tray_manager/tray_manager.dart:

class SystemTrayManager with TrayListener {
  final Ref ref;
  final bool enabled;

  SystemTrayManager(
    this.ref, {
    required this.enabled,
  }) {
    initialize();
  }

  Future<void> initialize() async {
    if (!kIsDesktop) return;

    if (enabled) {
      // Set platform-specific tray icon
      await trayManager.setIcon(
        kIsWindows
            ? 'assets/branding/spotube-logo.ico'  // .ico for Windows
            : kIsFlatpak
                ? 'com.github.KRTirtho.Spotube'  // App ID for Flatpak
                : 'assets/branding/spotube-logo.png',  // PNG for others
      );
      // Register this object as a listener for tray events
      trayManager.addListener(this);
    } else {
      // Clean up when tray is disabled
      await trayManager.destroy();
    }
  }

  void dispose() {
    trayManager.removeListener(this);
  }

  // Handle left-click on tray icon
  @override
  onTrayIconMouseDown() {
    if (kIsWindows) {
      // Windows convention: left-click shows the window
      windowManager.show();
    } else {
      // macOS/Linux convention: left-click shows context menu
      trayManager.popUpContextMenu();
    }
  }

  // Handle right-click on tray icon
  @override
  onTrayIconRightMouseDown() {
    if (!kIsWindows) {
      // macOS/Linux: right-click shows the window
      windowManager.show();
    } else {
      // Windows: right-click shows context menu
      trayManager.popUpContextMenu();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

Platform differences we handle:

  • Icon format: Windows requires .ico, Linux/Flatpak use PNG or app ID strings
  • Click behavior: Windows expects left-click to open, macOS/Linux expects right-click
  • Listener pattern: We implement the TrayListener interface to receive tray events

The Tray Manager Provider

Our Riverpod provider ties everything together:

final trayManagerProvider = Provider(
  (ref) {
    // Watch user preference for showing tray icon
    final enabled = ref.watch(
      userPreferencesProvider.select((s) => s.showSystemTrayIcon),
    );

    // Update tray menu reactively whenever app state changes
    ref.listen(trayMenuProvider, (_, menu) {
      if (!enabled || !kIsDesktop) return;
      trayManager.setContextMenu(menu);
    });

    // Create and manage the tray manager instance
    final manager = SystemTrayManager(
      ref,
      enabled: enabled,
    );

    // Clean up when provider is disposed
    ref.onDispose(manager.dispose);

    return manager;
  },
);

Enter fullscreen mode Exit fullscreen mode

Why this pattern is powerful:

  • Reactive updates: When some state (for Spotube, audio) changes (e.g. play/pause), the menu updates automatically
  • User control: Users can toggle tray icon from settings
  • Lifecycle management: Proper cleanup with ref.onDispose
  • Single source of truth: Audio player state is the source, tray menu is computed from it

Initializing the Tray in Your App

In your root app widget, initialize the tray manager early:

class Spotube extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, ref) {
    // Initialize tray manager
    ref.listen(trayManagerProvider, (_, __) {});

    // ... rest of your app
  }
}

Enter fullscreen mode Exit fullscreen mode


Desktop Notifications 🔔

Desktop notifications inform users about important events even when the app window is hidden or minimized. At Spotube, we use the local_notifier package to show native desktop notifications.

Initializing Local Notifications

In your lib/main.dart, initialize the notification system for desktop:

Future<void> main(List<String> rawArgs) async {
  final widgetsBinding = WidgetsFlutterBinding.ensureInitialized();

  if (kIsDesktop) {
    // Initialize local notifier with app name
    // This name appears in system notification settings
    await localNotifier.setup(appName: "Spotube");

    // Initialize other desktop services
    await WindowManagerTools.initialize();
  }

  // ... rest of initialization
}

Enter fullscreen mode Exit fullscreen mode

The Minimize to Tray Notification

When users minimize Spotube to the system tray, we show a notification to confirm the app is still running. This is handled in lib/hooks/configurators/use_close_behavior.dart:

import 'package:local_notifier/local_notifier.dart';

// This notification is created once and reused multiple times
final closeNotification = !kIsDesktop
    ? null
    : (LocalNotification(
        title: "'Spotube',"
        body: 'Running in background. Minimized to System Tray',
        actions: [
          LocalNotificationAction(text: 'Close The App'),
        ],
      )..onClickAction = (value) {
        // User clicked "Close The App" action
        exit(0);
      });

Enter fullscreen mode Exit fullscreen mode

Breaking this down:

  • Desktop-only: The notification is only created on desktop platforms
  • Nullable: On mobile, it's null, preventing crashes
  • Callback: onClickAction is invoked when the user clicks the notification action
  • Reusable: We create it once and call show() multiple times

Showing the Notification

When the user closes the window and has "minimize to tray" enabled:

void useCloseBehavior(WidgetRef ref) {
  useWindowListener(
    onWindowClose: () async {
      final preferences = ref.read(userPreferencesProvider);

      if (preferences.closeBehavior == CloseBehavior.minimizeToTray) {
        // Hide the window
        await windowManager.hide();

        // Show notification that app is still running
        closeNotification?.show();
      } else {
        // Close completely
        exit(0);
      }
    },
  );
}

Enter fullscreen mode Exit fullscreen mode

This notification gives users confidence that their music will keep playing and shows them how to close the app if needed.

Are you curious about this weird way of writing logical function? These look similar to react-hook, aren’t they? Actually, at Spotube, to reduce boilerplate and easier life-cycle handling of widgets, we use flutter_hooks package that is basically react-hooks but for Flutter. You can learn about the custom useWindowListener hook on part 1 of this series.

Building Custom Notifications

You can create notifications for other scenarios. Here's a pattern for doing it safely:

void showTrackChangeNotification(Track track) {
  if (!kIsDesktop) return;  // Only on desktop

  final notification = LocalNotification(
    title: "'Now Playing',"
    body: '${track.name}${track.artist}',
    silent: false,  // Play system sound
  );

  notification.show();
}

void showDownloadCompleteNotification(String fileName) {
  if (!kIsDesktop) return;

  final notification = LocalNotification(
    title: "'Download Complete',"
    body: fileName,
    actions: [
      LocalNotificationAction(text: 'Open Folder'),
    ],
  );

  notification.onClickAction = (value) {
    // Open the downloads folder
    openFileExplorer(downloadPath);
  };

  notification.show();
}

Enter fullscreen mode Exit fullscreen mode

Best practices:

  • Always check kIsDesktop: Prevents crashes on mobile
  • Provide meaningful titles and bodies: Users should understand what happened
  • Use actions sparingly: Notification actions are powerful but can be confusing
  • Avoid notification spam: Don't show notifications for every minor event

Putting It All Together: The Complete Desktop Experience

Here's how window management, system tray, and notifications work together in Spotube's initialization:

class MainApp extends HookConsumerWidget {
  @override
  Widget build(BuildContext context, ref) {
    // Make sure run this in the root app to Setup system tray with reactive menu
    // Riverpod providers are lazy, so we need to listen to it here to make sure it's initialized
    ref.listen(trayManagerProvider, (_, __) {});

    return ShadcnApp.router(
      // ... app configuration
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

The user flow:

  1. User clicks close buttonuseCloseBehavior intercepts
  2. Close preference checked → If "minimize to tray" is enabled...
  3. Window hideswindowManager.hide()
  4. Notification shows → User sees "Running in background"
  5. User interacts with tray → Platform-specific behavior triggers
    • Windows: Left-click shows window
    • macOS/Linux: Right-click shows window
  6. Tray menu displays → Reactive menu with current playback state
  7. User controls playback → Menu items trigger audio player actions
  8. Menu updates → State changes reflect in next menu open

This seamless integration is what makes Spotube feel like a native desktop application.

Happy building! 🚀


Follow the full 'Flutter Desktop Mastery' series on the official blog: https://spotube.krtirtho.dev/blog/flutter-desktop-mastery/part-2-system-tray-local-notifications/