Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
2 changes: 2 additions & 0 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import 'package:finamp/screens/playback_reporting_settings_screen.dart';
import 'package:finamp/screens/player_settings_screen.dart';
import 'package:finamp/screens/playlist_edit_screen.dart';
import 'package:finamp/screens/queue_restore_screen.dart';
import 'package:finamp/screens/universal_search_screen.dart';
import 'package:finamp/services/album_image_provider.dart';
import 'package:finamp/services/android_auto_helper.dart';
import 'package:finamp/services/audio_service_smtc.dart';
Expand Down Expand Up @@ -966,6 +967,7 @@ class FinampApp extends ConsumerWidget {
PlaybackHistoryScreen.routeName: (context) => const PlaybackHistoryScreen(),
LogsScreen.routeName: (context) => const LogsScreen(),
QueueRestoreScreen.routeName: (context) => const QueueRestoreScreen(),
UniversalSearchScreen.routeName: (context) => const UniversalSearchScreen(),
SettingsScreen.routeName: (context) => const SettingsScreen(),
HomeScreenSettingsScreen.routeName: (context) => const HomeScreenSettingsScreen(),
TranscodingSettingsScreen.routeName: (context) => const TranscodingSettingsScreen(),
Expand Down
13 changes: 2 additions & 11 deletions lib/screens/music_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import 'package:finamp/l10n/app_localizations.dart';
import 'package:finamp/menus/music_screen_drawer.dart';
import 'package:finamp/models/finamp_models.dart';
import 'package:finamp/models/music_models.dart';
import 'package:finamp/screens/universal_search_screen.dart';
import 'package:finamp/services/audio_service_helper.dart';
import 'package:finamp/services/finamp_settings_helper.dart';
import 'package:finamp/services/finamp_user_helper.dart';
Expand Down Expand Up @@ -236,17 +237,7 @@ class _MusicScreenState extends ConsumerState<MusicScreen> with TickerProviderSt
singleTabConfig: widget.singleTabConfig,
sortedTabs: sortedTabs.toList(),
tabController: _tabController,
onSearch: () => setState(() {
isSearching = true;
if (_tabController != null &&
!_tabController!.indexIsChanging &&
sortedTabs.elementAt(_tabController!.index) == ContentType.home) {
// we can't search on the home tab yet
_tabController!.index = sortedTabs.toList().indexWhere(
(ContentType tabType) => tabType != ContentType.home,
);
}
}),
onSearch: () => Navigator.of(context).pushNamed(UniversalSearchScreen.routeName),
onStopSearch: _stopSearching,
onUpdateSearchQuery: (value) {
setState(() {
Expand Down
204 changes: 204 additions & 0 deletions lib/screens/universal_search_screen.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import 'dart:async';

import 'package:flutter/material.dart';
import 'package:get_it/get_it.dart';

import '../components/AlbumScreen/track_list_tile.dart';
import '../components/MusicScreen/item_wrapper.dart';
import '../extensions/localizations.dart';
import '../models/finamp_models.dart';
import '../models/jellyfin_models.dart';
import '../models/music_models.dart';
import '../services/finamp_settings_helper.dart';
import '../services/jellyfin_api_helper.dart';

/// A global search screen that queries the Jellyfin server across tracks,
/// albums, artists, playlists and genres, replacing the old in-tab search.
class UniversalSearchScreen extends StatefulWidget {
const UniversalSearchScreen({super.key});

static const routeName = "/search";

@override
State<UniversalSearchScreen> createState() => _UniversalSearchScreenState();
}

class _UniversalSearchScreenState extends State<UniversalSearchScreen> {
final _searchController = TextEditingController();
final _jellyfinApiHelper = GetIt.instance<JellyfinApiHelper>();
Timer? _debounce;
Future<List<BaseItemDto>>? _searchFuture;
String _query = "";

@override
void dispose() {
_debounce?.cancel();
_searchController.dispose();
super.dispose();
}

void _onSearchChanged(String value) {
_debounce?.cancel();
_debounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_query = value.trim();
_searchFuture = _query.isEmpty ? null : _performSearch(_query);
});
});
}

Future<List<BaseItemDto>> _performSearch(String query) async {
// A plain search term only matches item names, so searching an artist's
// name won't surface their albums/tracks (whose titles don't contain the
// name). We run the name search, then expand any matched artists into their
// discography so e.g. "john williams" also lists his albums and tracks.
final nameMatches = await _jellyfinApiHelper.getItems(
includeItemTypes: "Audio,MusicAlbum,MusicArtist,Playlist,MusicGenre",
searchTerm: query,
sortBy: "SortName",
sortOrder: "Ascending",
startIndex: 0,
limit: 100,
) ??
[];

final artists = nameMatches
.where((i) => BaseItemDtoType.fromItem(i) == BaseItemDtoType.artist)
.toList();

// Fetch each matched artist's albums and tracks. These must be separate
// requests: with a recursive query, a combined "MusicAlbum,Audio" fetch
// orders the handful of albums behind hundreds of tracks, so albums can
// fall outside the limit and never appear.
final discographies = await Future.wait(
artists.expand((artist) => [
_jellyfinApiHelper.getItems(
parentItem: artist,
artistType: ArtistType.albumArtist,
includeItemTypes: "MusicAlbum",
sortBy: "SortName",
sortOrder: "Ascending",
limit: 200,
).then((r) => r ?? <BaseItemDto>[]),
_jellyfinApiHelper.getItems(
parentItem: artist,
artistType: ArtistType.albumArtist,
includeItemTypes: "Audio",
sortBy: "SortName",
sortOrder: "Ascending",
limit: 200,
).then((r) => r ?? <BaseItemDto>[]),
]),
);

// Merge everything, de-duplicating by id, then order by type so artists and
// albums surface above the long tail of tracks.
final byId = <String, BaseItemDto>{};
for (final item in [nameMatches, ...discographies].expand((l) => l)) {
final id = item.id.raw;
byId.putIfAbsent(id, () => item);
}

const typeRank = {
BaseItemDtoType.artist: 0,
BaseItemDtoType.album: 1,
BaseItemDtoType.playlist: 2,
BaseItemDtoType.genre: 3,
BaseItemDtoType.track: 4,
};
final merged = byId.values.toList()
..sort((a, b) {
final ra = typeRank[BaseItemDtoType.fromItem(a)] ?? 5;
final rb = typeRank[BaseItemDtoType.fromItem(b)] ?? 5;
if (ra != rb) return ra.compareTo(rb);
return (a.name ?? "").toLowerCase().compareTo((b.name ?? "").toLowerCase());
});

return merged;
}

Widget _buildResultItem(List<BaseItemDto> results, List<BaseItemDto> tracks, int index) {
final item = results[index];
if (BaseItemDtoType.fromItem(item) == BaseItemDtoType.track) {
return TrackListTile(
key: ValueKey(item.id),
item: item,
showIndex: false,
showCover: true,
// Index of this track within the queueable tracks, used to start
// playback at the tapped track.
index: tracks.indexWhere((t) => t.id == item.id),
parentPlayable: PrecalculatedPlayable(
source: QueueItemSource.rawId(
type: QueueItemSourceType.unknown,
name: QueueItemSourceName(
type: QueueItemSourceNameType.preTranslated,
pretranslatedName:
MaterialLocalizations.of(context).searchFieldLabel,
),
id: "universal-search",
),
// Only the tracks in the results should be queueable together.
tracks: tracks,
),
);
}

// ItemWrapper handles tap navigation (artist/album/playlist/genre screens)
// and the long-press context menu.
return ItemWrapper(
key: ValueKey(item.id),
item: item,
);
}

@override
Widget build(BuildContext context) {
final isOffline = FinampSettingsHelper.finampSettings.isOffline;

return Scaffold(
appBar: AppBar(
title: TextField(
controller: _searchController,
autofocus: true,
onChanged: _onSearchChanged,
decoration: InputDecoration(
border: InputBorder.none,
hintText: MaterialLocalizations.of(context).searchFieldLabel,
),
),
scrolledUnderElevation: 0,
),
body: isOffline
? Center(child: Text(context.l10n.notAvailableInOfflineMode))
: _searchFuture == null
? const SizedBox.shrink()
: FutureBuilder<List<BaseItemDto>>(
future: _searchFuture,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}

final results = snapshot.data ?? [];
if (results.isEmpty) {
return Center(child: Text(context.l10n.nothingFoundToPlay));
}

final tracks = results
.where((i) => BaseItemDtoType.fromItem(i) == BaseItemDtoType.track)
.toList();

return ListView.builder(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
itemCount: results.length,
itemBuilder: (context, index) =>
_buildResultItem(results, tracks, index),
);
},
),
);
}
}