Skip to content

Commit

Permalink
feat(app): add system tray functionality (#824)
Browse files Browse the repository at this point in the history
* feat(app): add system tray functionality

- Update pubspec.yaml with tray_manager dependency.
- Initialize system tray in app.dart and handle menu item clicks.

* feat: tray menu for linux

* fix: key names

---------

Co-authored-by: Feichtmeier <[email protected]>
  • Loading branch information
dongfengweixiao and Feichtmeier authored Aug 21, 2024
1 parent 3bda489 commit 857bdef
Show file tree
Hide file tree
Showing 25 changed files with 525 additions and 35 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@ jobs:
channel: 'stable'
flutter-version: ${{env.FLUTTER_VERSION}}
- run: sudo apt update
- run: sudo apt install -y clang cmake curl libgtk-3-dev ninja-build pkg-config unzip libunwind-dev libmpv-dev
- run: sudo apt install -y clang cmake curl libgtk-3-dev ninja-build pkg-config unzip libunwind-dev libmpv-dev libappindicator3-1 libappindicator3-dev
- run: flutter pub get
- run: flutter build linux -v
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ jobs:
# channel: 'stable'
# flutter-version: ${{env.FLUTTER_VERSION}}
# - run: sudo apt update
# - run: sudo apt install -y clang cmake curl libgtk-3-dev ninja-build pkg-config unzip libunwind-dev libmpv-dev
# - run: sudo apt install -y clang cmake curl libgtk-3-dev ninja-build pkg-config unzip libunwind-dev libmpv-dev libappindicator3-1 libappindicator3-dev
# - run: flutter pub get

# - uses: snapcore/action-build@v1
Expand Down
Binary file added assets/images/tray_icon.ico
Binary file not shown.
Binary file added assets/images/tray_icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
36 changes: 35 additions & 1 deletion lib/app/view/app.dart
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import 'dart:io';
import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:phoenix_theme/phoenix_theme.dart' hide ColorX, isMobile;
import 'package:system_theme/system_theme.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:watch_it/watch_it.dart';
import 'package:window_manager/window_manager.dart';
import 'package:yaru/yaru.dart';

import '../../common/view/icons.dart';
Expand All @@ -14,6 +17,7 @@ import '../../library/library_model.dart';
import '../../settings/settings_model.dart';
import 'scaffold.dart';
import 'splash_screen.dart';
import 'system_tray.dart';

class YaruMusicPodApp extends StatelessWidget {
const YaruMusicPodApp({
Expand Down Expand Up @@ -105,7 +109,8 @@ class _MusicPodApp extends StatefulWidget with WatchItStatefulWidgetMixin {
State<_MusicPodApp> createState() => _MusicPodAppState();
}

class _MusicPodAppState extends State<_MusicPodApp> {
class _MusicPodAppState extends State<_MusicPodApp>
with WindowListener, TrayListener {
late Future<bool> _initFuture;

@override
Expand All @@ -118,9 +123,38 @@ class _MusicPodAppState extends State<_MusicPodApp> {
await di<LibraryModel>().init();
if (!mounted) return false;
di<ExternalPathService>().init();
if (Platform.isLinux) {
windowManager.addListener(this);
trayManager.addListener(this);
}
return true;
}

@override
void dispose() {
if (Platform.isLinux) {
windowManager.removeListener(this);
trayManager.removeListener(this);
}
super.dispose();
}

@override
void onTrayIconMouseDown() {
trayManager.popUpContextMenu();
}

@override
void onWindowEvent(String eventName) {
if ('show' == eventName || 'hide' == eventName) {
updateTrayItems(context);
}
super.onWindowEvent(eventName);
}

@override
void onTrayMenuItemClick(MenuItem menuItem) => reactToTray(menuItem);

@override
Widget build(BuildContext context) {
final themeIndex = watchPropertyValue((SettingsModel m) => m.themeIndex);
Expand Down
65 changes: 65 additions & 0 deletions lib/app/view/system_tray.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import 'dart:io';

import 'package:flutter/material.dart';
import 'package:tray_manager/tray_manager.dart';
import 'package:window_manager/window_manager.dart';

import '../../l10n/l10n.dart';

const _exitAppMenuKey = 'exit_app';
const _showHideWindowMenuKey = 'show_hide_window';

Future<void> initTray() async {
await trayManager.setIcon(
(Platform.isWindows)
? 'assets/images/tray_icon.ico'
: 'assets/images/tray_icon.png',
);
Menu menu = Menu(
items: [
MenuItem(
key: _showHideWindowMenuKey,
label: 'Show Window',
),
MenuItem.separator(),
MenuItem(
key: _exitAppMenuKey,
label: 'Exit App',
),
],
);
await trayManager.setContextMenu(menu);
}

Future<void> updateTrayItems(BuildContext context) async {
bool isVisible = await windowManager.isVisible();
if (!context.mounted) return;
final trayMenuItems = [
MenuItem(
key: _showHideWindowMenuKey,
label: isVisible ? context.l10n.hideToTray : context.l10n.closeApp,
),
MenuItem.separator(),
MenuItem(
key: _exitAppMenuKey,
label: context.l10n.closeApp,
),
];

await trayManager.setContextMenu(Menu(items: trayMenuItems));
}

void reactToTray(MenuItem menuItem) {
switch (menuItem.key) {
case _showHideWindowMenuKey:
windowManager.isVisible().then((value) {
if (value) {
windowManager.hide();
} else {
windowManager.show();
}
});
case _exitAppMenuKey:
windowManager.close();
}
}
16 changes: 16 additions & 0 deletions lib/common/data/close_btn_action.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import '../../l10n/l10n.dart';

enum CloseBtnAction {
alwaysAsk,
hideToTray,
close;

@override
String toString() => name;

String localize(AppLocalizations l10n) => switch (this) {
alwaysAsk => l10n.alwaysAsk,
hideToTray => l10n.hideToTray,
close => l10n.closeApp,
};
}
93 changes: 93 additions & 0 deletions lib/common/view/header_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@ import 'dart:io';

import '../../app/app_model.dart';
import '../../extensions/build_context_x.dart';
import '../../l10n/l10n.dart';
import '../../library/library_model.dart';
import '../../settings/settings_model.dart';
import '../data/close_btn_action.dart';
import 'global_keys.dart';
import 'icons.dart';
import 'nav_back_button.dart';
Expand All @@ -11,6 +14,8 @@ import 'package:phoenix_theme/phoenix_theme.dart' hide isMobile;
import 'package:watch_it/watch_it.dart';
import 'package:yaru/yaru.dart';

import 'theme.dart';

class HeaderBar extends StatelessWidget
with WatchItMixin
implements PreferredSizeWidget {
Expand Down Expand Up @@ -40,6 +45,8 @@ class HeaderBar extends StatelessWidget
@override
Widget build(BuildContext context) {
final canPop = watchPropertyValue((LibraryModel m) => m.canPop);
final closeBtnAction =
watchPropertyValue((SettingsModel m) => m.closeBtnActionIndex);

Widget? leading;

Expand Down Expand Up @@ -89,6 +96,21 @@ class HeaderBar extends StatelessWidget
backgroundColor: backgroundColor ?? context.theme.scaffoldBackgroundColor,
style: theStyle,
foregroundColor: foregroundColor,
onClose: Platform.isLinux
? (context) {
switch (closeBtnAction) {
case CloseBtnAction.alwaysAsk:
showDialog(
context: context,
builder: (_) => const CloseWindowActionConfirmDialog(),
);
case CloseBtnAction.hideToTray:
YaruWindow.hide(context);
case CloseBtnAction.close:
YaruWindow.close(context);
}
}
: null,
);
}

Expand All @@ -101,6 +123,77 @@ class HeaderBar extends StatelessWidget
);
}

class CloseWindowActionConfirmDialog extends StatefulWidget {
const CloseWindowActionConfirmDialog({super.key});

@override
State<CloseWindowActionConfirmDialog> createState() =>
_CloseWindowActionConfirmDialogState();
}

class _CloseWindowActionConfirmDialogState
extends State<CloseWindowActionConfirmDialog> {
bool rememberChoice = false;
@override
Widget build(BuildContext context) {
final model = di<SettingsModel>();
return AlertDialog(
title: yaruStyled
? YaruDialogTitleBar(
backgroundColor: Colors.transparent,
title: Text(context.l10n.closeMusicPod),
)
: null,
titlePadding: yaruStyled ? EdgeInsets.zero : null,
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.error,
color: Colors.red,
size: 50,
),
const SizedBox(height: 12),
Text(
context.l10n.confirmCloseOrHideTip,
),
CheckboxListTile(
title: Text(context.l10n.doNotAskAgain),
value: rememberChoice,
onChanged: (value) {
setState(() {
rememberChoice = value!;
});
},
),
],
),
actions: [
TextButton(
onPressed: () {
if (rememberChoice) {
model.setCloseBtnActionIndex(CloseBtnAction.hideToTray);
}
Navigator.of(context).pop();
YaruWindow.hide(context);
},
child: Text(context.l10n.hideToTray),
),
TextButton(
onPressed: () {
if (rememberChoice) {
model.setCloseBtnActionIndex(CloseBtnAction.close);
}
Navigator.of(context).pop();
YaruWindow.close(context);
},
child: Text(context.l10n.closeApp),
),
],
);
}
}

class SidebarButton extends StatelessWidget {
const SidebarButton({super.key});

Expand Down
1 change: 1 addition & 0 deletions lib/constants.dart
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ const kFavCountryCodes = 'favCountryCodes';
const kFavLanguageCodes = 'favLanguageCodes';
const kAscendingFeeds = 'ascendingfeed:::';
const kPatchNotesDisposed = 'kPatchNotesDisposed';
const kCloseBtnAction = 'closeBtnAction';

const shops = <String, String>{
'https://us.7digital.com/': '7digital',
Expand Down
10 changes: 9 additions & 1 deletion lib/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -331,5 +331,13 @@
"replayAllEpisodes": "Replay all episodes",
"checkForUpdates": "Check for updates",
"playbackWillStopIn": "Playback will stop in: {duration} ({timeOfDay})",
"schedulePlaybackStopTimer": "Schedule a time to stop playback"
"schedulePlaybackStopTimer": "Schedule a time to stop playback",
"alwaysAsk": "Always ask",
"hideToTray": "Hide to tray",
"closeBtnAction": "Close Button Action",
"whenCloseBtnClicked": "When close button is clicked",
"closeApp": "Close Application",
"closeMusicPod": "Close MusicPod?",
"confirmCloseOrHideTip": "Please confirm if you need to close the application or hide it?",
"doNotAskAgain": "Do not ask again"
}
13 changes: 11 additions & 2 deletions lib/l10n/app_zh.arb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"cryptocurrencyXXXPodcastIndexOnly": "加密货币",
"cultureXXXPodcastIndexOnly": "文化",
"dailyXXXPodcastIndexOnly": "每日",
"dark": "暗色",
"dark": "浅色",
"decreaseSearchLimit": "请减少搜索限制",
"deletePlaylist": "删除播放列表",
"dependencies": "依赖",
Expand Down Expand Up @@ -330,5 +330,14 @@
"writeMetadata": "写入元数据",
"year": "年份",
"years": "年份",
"playbackWillStopIn": "播放将在 {duration} ({timeOfDay})后停止。"
"playbackWillStopIn": "播放将在 {duration} ({timeOfDay})后停止。",
"schedulePlaybackStopTimer": "计划停止播放的时间",
"alwaysAsk": "总是询问",
"hideToTray": "隐藏到托盘",
"closeBtnAction": "关闭按钮的行为",
"whenCloseBtnClicked": "当点击关闭按钮时",
"closeApp": "关闭应用",
"closeMusicPod": "关闭 MusicPod?",
"confirmCloseOrHideTip": "请确定您想要退出应用还是隐藏到托盘?",
"doNotAskAgain": "不再询问"
}
13 changes: 11 additions & 2 deletions lib/l10n/app_zh_CN.arb
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@
"cryptocurrencyXXXPodcastIndexOnly": "加密货币",
"cultureXXXPodcastIndexOnly": "文化",
"dailyXXXPodcastIndexOnly": "每日",
"dark": "暗色",
"dark": "浅色",
"decreaseSearchLimit": "请减少搜索限制",
"deletePlaylist": "删除播放列表",
"dependencies": "依赖",
Expand Down Expand Up @@ -330,5 +330,14 @@
"writeMetadata": "写入元数据",
"year": "年份",
"years": "年份",
"playbackWillStopIn": "播放将在 {duration} ({timeOfDay})后停止。"
"playbackWillStopIn": "播放将在 {duration} ({timeOfDay})后停止。",
"schedulePlaybackStopTimer": "计划停止播放的时间",
"alwaysAsk": "总是询问",
"hideToTray": "隐藏到托盘",
"closeBtnAction": "关闭按钮的行为",
"whenCloseBtnClicked": "当点击关闭按钮时",
"closeApp": "关闭应用",
"closeMusicPod": "关闭 MusicPod?",
"confirmCloseOrHideTip": "请确定您想要退出应用还是隐藏到托盘?",
"doNotAskAgain": "不再询问"
}
5 changes: 5 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import '../../library/library_model.dart';
import 'app/app_model.dart';
import 'app/connectivity_model.dart';
import 'app/view/app.dart';
import 'app/view/system_tray.dart';
import 'library/library_service.dart';
import 'local_audio/local_audio_model.dart';
import 'local_audio/local_audio_service.dart';
Expand Down Expand Up @@ -133,6 +134,10 @@ Future<void> main(List<String> args) async {
final gitHub = GitHub();
di.registerSingleton<GitHub>(gitHub);

if (Platform.isLinux) {
await initTray();
}

// Register ViewModels
di.registerLazySingleton<SettingsModel>(
() => SettingsModel(
Expand Down
Loading

0 comments on commit 857bdef

Please sign in to comment.