Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import 'dart:async';
import 'package:app/audio_handler.dart';
import 'package:app/providers/providers.dart';
import 'package:app/ui/app.dart';
import 'package:app/utils/quick_actions.dart';
import 'package:audio_service/audio_service.dart';
import 'package:flutter/material.dart';
import 'package:get_storage/get_storage.dart';
import 'package:provider/provider.dart';
import 'package:provider/single_child_widget.dart';

late KoelAudioHandler audioHandler;
final quickActions = KoelQuickActions();

List<SingleChildWidget> _providers = [
Provider(create: (_) => AuthProvider()),
Expand Down Expand Up @@ -104,6 +106,10 @@ Future<void> main() async {
await GetStorage.init('Preferences');
await GetStorage.init(DownloadProvider.serializedPlayableContainer);

// Register early so a cold launch via a shortcut is captured and buffered
// until the main screen is ready to act on it.
quickActions.initialize();

runApp(
MultiProvider(
providers: _providers,
Expand Down
87 changes: 85 additions & 2 deletions lib/ui/screens/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,13 @@ import 'package:app/constants/constants.dart';
import 'package:app/enums.dart';
import 'package:app/main.dart';
import 'package:app/mixins/stream_subscriber.dart';
import 'package:app/models/models.dart';
import 'package:app/providers/providers.dart';
import 'package:app/ui/screens/screens.dart';
import 'package:app/ui/widgets/widgets.dart';
import 'package:app/utils/quick_actions.dart';
import 'package:app/utils/route_state.dart';
import 'package:audio_service/audio_service.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
Expand All @@ -23,8 +26,9 @@ class MainScreen extends StatefulWidget {
_MainScreenState createState() => _MainScreenState();
}

class _MainScreenState extends State<MainScreen> {
class _MainScreenState extends State<MainScreen> with StreamSubscriber {
static const tabBarHeight = 60.0;
static const _searchTabIndex = 1;
late int _selectedIndex;
var _isOffline = AppState.get('mode', AppMode.online) == AppMode.offline;

Expand Down Expand Up @@ -68,7 +72,86 @@ class _MainScreenState extends State<MainScreen> {

context.read<DownloadSyncProvider>().scheduleSync();

WidgetsBinding.instance.addPostFrameCallback((_) => _restoreRoutes());
_setUpQuickActions();

WidgetsBinding.instance.addPostFrameCallback((_) {
_restoreRoutes();

final pendingAction = quickActions.consumePendingAction();
if (pendingAction != null) _handleQuickAction(pendingAction);
});
}

@override
void dispose() {
unsubscribeAll();
super.dispose();
}

void _setUpQuickActions() {
void refreshShortcuts(MediaItem? item) {
quickActions.setShortcuts(
recentSubtitle: KoelQuickActions.recentSubtitle(
artist: item?.artist,
title: item?.title,
),
);
}

refreshShortcuts(audioHandler.mediaItem.value);
subscribe(audioHandler.mediaItem.listen(refreshShortcuts));
subscribe(quickActions.actions.listen(_handleQuickAction));
}

Future<void> _handleQuickAction(String type) async {
switch (type) {
case KoelQuickActions.search:
_gotoSearchAndFocus();
break;
case KoelQuickActions.playFavorites:
final favoriteProvider = context.read<FavoriteProvider>();
await _shufflePlay(() => favoriteProvider.fetch());
break;
case KoelQuickActions.playDownloaded:
final downloadProvider = context.read<DownloadProvider>();
await _shufflePlay(() async => downloadProvider.playables);
break;
case KoelQuickActions.playRecent:
await _resumeQueue();
break;
}
}

void _gotoSearchAndFocus() {
if (_selectedIndex != _searchTabIndex) {
setState(() => _selectedIndex = _searchTabIndex);
RouteState.setTabIndex(_searchTabIndex);
}
quickActions.requestSearchFocus();
}

Future<void> _shufflePlay(
Future<List<Playable>> Function() loadPlayables) async {
try {
final playables = await loadPlayables();
if (playables.isNotEmpty) {
await audioHandler.replaceQueue(playables, shuffle: true);
}
} catch (_) {
// A quick action fails quietly (e.g. a favourites fetch while offline).
}
}

Future<void> _resumeQueue() async {
// On a cold launch the persisted queue is restored asynchronously, so wait
// briefly for it before resuming.
if (audioHandler.queue.value.isEmpty) {
await audioHandler.queue
.firstWhere((items) => items.isNotEmpty)
.timeout(const Duration(seconds: 5), onTimeout: () => <MediaItem>[]);
}

if (audioHandler.queue.value.isNotEmpty) await audioHandler.play();
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

void _restoreRoutes() {
Expand Down
23 changes: 22 additions & 1 deletion lib/ui/screens/search.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import 'package:app/constants/constants.dart';
import 'package:app/main.dart';
import 'package:app/mixins/stream_subscriber.dart';
import 'package:app/models/models.dart';
import 'package:app/providers/providers.dart';
import 'package:app/ui/widgets/widgets.dart';
Expand All @@ -17,7 +19,7 @@ class SearchScreen extends StatefulWidget {
_SearchScreenState createState() => _SearchScreenState();
}

class _SearchScreenState extends State<SearchScreen> {
class _SearchScreenState extends State<SearchScreen> with StreamSubscriber {
var _hasFocus = false;
var _initial = true;
var _playables = <Playable>[];
Expand All @@ -38,6 +40,25 @@ class _SearchScreenState extends State<SearchScreen> {
_focusNode.addListener(() {
setState(() => _hasFocus = _focusNode.hasFocus);
});

// Focus the field when reached via the "Search" quick action, whether this
// screen was already alive or is being built as a result of the action.
if (quickActions.consumePendingSearchFocus()) _focusSearchField();
subscribe(
quickActions.searchFocusRequests.listen((_) => _focusSearchField()),
);
}

void _focusSearchField() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _focusNode.requestFocus();
});
}

@override
void dispose() {
unsubscribeAll();
super.dispose();
}

_search(String keywords) =>
Expand Down
96 changes: 96 additions & 0 deletions lib/utils/quick_actions.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import 'dart:async';

import 'package:quick_actions/quick_actions.dart';

/// Wraps the home-screen quick actions (long-press the app icon) and relays
/// them to the app. Actions received before a listener is ready (e.g. a cold
/// launch opened via a shortcut) are buffered and consumed once the app is up.
class KoelQuickActions {
static const search = 'koel_action_search';
static const playFavorites = 'koel_action_play_favorites';
static const playDownloaded = 'koel_action_play_downloaded';
static const playRecent = 'koel_action_play_recent';

final QuickActions _quickActions;
final _actions = StreamController<String>.broadcast();
final _searchFocusRequests = StreamController<void>.broadcast();

String? _pendingAction;
var _searchFocusPending = false;

KoelQuickActions({QuickActions quickActions = const QuickActions()})
: _quickActions = quickActions;

/// Emits a shortcut type each time the user triggers a quick action while the
/// app is running.
Stream<String> get actions => _actions.stream;

/// Emits whenever the Search field should grab focus.
Stream<void> get searchFocusRequests => _searchFocusRequests.stream;

void initialize() {
_quickActions.initialize((type) {
_pendingAction = type;
_actions.add(type);
});
}

/// Returns and clears an action received before a listener attached (a cold
/// launch via a shortcut), or null if there was none.
String? consumePendingAction() {
final action = _pendingAction;
_pendingAction = null;
return action;
}

void requestSearchFocus() {
_searchFocusPending = true;
_searchFocusRequests.add(null);
}

/// True once if a focus request arrived before the Search screen was built.
bool consumePendingSearchFocus() {
final pending = _searchFocusPending;
_searchFocusPending = false;
return pending;
}

Future<void> setShortcuts({String? recentSubtitle}) {
return _quickActions
.setShortcutItems(shortcutItems(recentSubtitle: recentSubtitle));
}

/// The shortcut items to expose. "Play Most Recent" is only offered when
/// there is a recent track to describe.
static List<ShortcutItem> shortcutItems({String? recentSubtitle}) {
return [
const ShortcutItem(type: search, localizedTitle: 'Search'),
const ShortcutItem(
type: playFavorites,
localizedTitle: 'Play Favorite Songs',
),
const ShortcutItem(
type: playDownloaded,
localizedTitle: 'Play Downloaded',
),
if (recentSubtitle != null && recentSubtitle.isNotEmpty)
ShortcutItem(
type: playRecent,
localizedTitle: 'Play Most Recent',
localizedSubtitle: recentSubtitle,
),
];
}

/// Formats a "Artist - Title" label for the most-recent track, or just the
/// title when there's no artist.
static String? recentSubtitle({String? artist, String? title}) {
if (title == null || title.isEmpty) return null;
return (artist == null || artist.isEmpty) ? title : '$artist - $title';
}

void dispose() {
_actions.close();
_searchFocusRequests.close();
}
}
36 changes: 34 additions & 2 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -737,6 +737,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.5.0"
quick_actions:
dependency: "direct main"
description:
name: quick_actions
sha256: "7e35dd6a21f5bbd21acf6899039eaf85001a5ac26d52cbd6a8a2814505b90798"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
quick_actions_android:
dependency: transitive
description:
name: quick_actions_android
sha256: "6fbdda87fbedd0602b91f35d870c2cbcb6d053bc863efb76c6e7f60f1d8e3fd6"
url: "https://pub.dev"
source: hosted
version: "1.0.30"
quick_actions_ios:
dependency: transitive
description:
name: quick_actions_ios
sha256: be1496e7ca1debc86d9ea08e56325649fbc5abb2b6930690c97ba0dae59992b1
url: "https://pub.dev"
source: hosted
version: "1.2.4"
quick_actions_platform_interface:
dependency: transitive
description:
name: quick_actions_platform_interface
sha256: "1fec7068db5122cd019e9340d3d7be5d36eab099695ef3402c7059ee058329a4"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
rxdart:
dependency: "direct dev"
description:
Expand Down Expand Up @@ -1071,5 +1103,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
dart: ">=3.9.0-0 <4.0.0"
flutter: ">=3.27.0"
dart: ">=3.10.0 <4.0.0"
flutter: ">=3.38.0"
1 change: 1 addition & 0 deletions pubspec.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dependencies:
faker: any
ulid: ^2.0.1
lucide_icons_flutter: ^3.1.14+2
quick_actions: ^1.1.0
dev_dependencies:
fake_async: ^1.2.0
mockito: ^5.0.12
Expand Down
Loading
Loading