Skip to content

Commit 7d8f002

Browse files
committed
feat: Search improvements
- MusicService.searchSongs and implementations are now an AsyncGenerator, and it's now possible to abort them using AbortSignal - MusicKitMusicService no longer parses search results as MussicKitSong - YoutubeMusicService now supports continuation - SearchPage automatically closes on-screen keyboard after pressing return - SearchPage's searchbar weird blur is fixed - SearchPage now supports infinite scroll
1 parent 2a5860b commit 7d8f002

File tree

8 files changed

+305
-94
lines changed

8 files changed

+305
-94
lines changed

src/components/AppPage.vue

+79-43
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,17 @@ import {
1313
import { personCircle as personIcon } from "ionicons/icons";
1414
1515
import { createSettingsModal } from "@/components/AppSettingsModal.vue";
16-
import { computed } from "vue";
17-
18-
const { title, backButton } = defineProps<{
16+
import { useElementBounding } from "@vueuse/core";
17+
import { computed, ref, useTemplateRef, watch } from "vue";
18+
19+
const {
20+
title,
21+
backButton,
22+
showHeader = true,
23+
} = defineProps<{
1924
title?: string;
2025
backButton?: string;
26+
showHeader?: boolean;
2127
}>();
2228
2329
const slots = defineSlots<{
@@ -46,51 +52,35 @@ async function openSettings(): Promise<void> {
4652
await modal.present();
4753
await modal.onDidDismiss();
4854
}
55+
56+
const header = useTemplateRef("header");
57+
58+
const bounding = useElementBounding(header);
59+
const headerHeight = ref(0);
60+
watch(bounding.height, (height) => {
61+
headerHeight.value = Math.max(headerHeight.value, height);
62+
});
4963
</script>
5064

5165
<template>
52-
<ion-page>
53-
<ion-header translucent>
54-
<slot name="header-leading" />
55-
56-
<slot name="toolbar">
57-
<ion-toolbar>
58-
<div slot="start">
59-
<slot name="toolbar-start">
60-
<ion-buttons v-if="backButton !== undefined">
61-
<ion-back-button :text="backButton" />
62-
</ion-buttons>
63-
</slot>
64-
</div>
65-
66-
<ion-title v-if="title">{{ title }}</ion-title>
67-
68-
<div v-if="!inlineView" slot="end">
69-
<slot name="toolbar-end">
70-
<ion-buttons slot="end">
71-
<ion-button @click="openSettings">
72-
<ion-icon size="large" slot="icon-only" :icon="personIcon" />
73-
</ion-button>
74-
</ion-buttons>
75-
</slot>
76-
</div>
77-
</ion-toolbar>
78-
79-
<slot name="header-trailing" />
80-
</slot>
81-
</ion-header>
82-
83-
<ion-content fullscreen>
84-
<ion-header v-if="title" collapse="condense">
66+
<ion-page id="app-page" :style="{ '--header-height': `${headerHeight}px` }">
67+
<Transition name="slide">
68+
<ion-header v-if="showHeader" translucent>
8569
<slot name="header-leading" />
8670

8771
<slot name="toolbar">
8872
<ion-toolbar>
89-
<slot name="toolbar-leading" />
73+
<div slot="start">
74+
<slot name="toolbar-start">
75+
<ion-buttons v-if="backButton !== undefined">
76+
<ion-back-button :text="backButton" />
77+
</ion-buttons>
78+
</slot>
79+
</div>
9080

91-
<ion-title size="large">{{ title }}</ion-title>
81+
<ion-title v-if="title">{{ title }}</ion-title>
9282

93-
<div v-if="inlineView" slot="end">
83+
<div v-if="!inlineView" slot="end">
9484
<slot name="toolbar-end">
9585
<ion-buttons slot="end">
9686
<ion-button @click="openSettings">
@@ -99,20 +89,66 @@ async function openSettings(): Promise<void> {
9989
</ion-buttons>
10090
</slot>
10191
</div>
102-
103-
<slot name="toolbar-trailing" />
10492
</ion-toolbar>
105-
</slot>
10693

107-
<slot name="header-trailing" />
94+
<slot name="header-trailing" />
95+
</slot>
10896
</ion-header>
97+
</Transition>
98+
99+
<ion-content fullscreen>
100+
<Transition name="slide">
101+
<ion-header ref="header" v-if="showHeader && title" collapse="condense">
102+
<slot name="header-leading" />
103+
104+
<slot name="toolbar">
105+
<ion-toolbar>
106+
<slot name="toolbar-leading" />
107+
108+
<ion-title size="large">{{ title }}</ion-title>
109+
110+
<div v-if="inlineView" slot="end">
111+
<slot name="toolbar-end">
112+
<ion-buttons slot="end">
113+
<ion-button @click="openSettings">
114+
<ion-icon size="large" slot="icon-only" :icon="personIcon" />
115+
</ion-button>
116+
</ion-buttons>
117+
</slot>
118+
</div>
119+
120+
<slot name="toolbar-trailing" />
121+
</ion-toolbar>
122+
</slot>
123+
124+
<slot name="header-trailing" />
125+
</ion-header>
126+
</Transition>
109127

110128
<slot />
111129
</ion-content>
112130
</ion-page>
113131
</template>
114132

115133
<style scoped>
134+
@keyframes show-header {
135+
from {
136+
height: 0;
137+
}
138+
139+
to {
140+
height: var(--header-height);
141+
}
142+
}
143+
144+
.slide-enter-active {
145+
animation: show-header 150ms cubic-bezier(0.32, 0.885, 0.55, 1.175) forwards;
146+
}
147+
148+
.slide-leave-active {
149+
animation: show-header 150ms cubic-bezier(0.175, 0.885, 0.32, 1.075) reverse forwards;
150+
}
151+
116152
ion-back-button {
117153
display: block;
118154
}

src/components/MusicPlayer.vue

+1
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,7 @@ function dismiss(): void {
396396
@media screen and (min-width: 640px) {
397397
--modal-handle-top: calc(var(--ion-safe-area-top) + 24px);
398398
}
399+
399400
&::part(handle) {
400401
background-color: white;
401402
opacity: 80%;

src/pages/Search/SearchPage.vue

+55-19
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script setup lang="ts">
22
import {
3-
IonHeader,
3+
InfiniteScrollCustomEvent,
44
IonIcon,
5+
IonInfiniteScroll,
6+
IonInfiniteScrollContent,
57
IonItem,
68
IonLabel,
79
IonList,
@@ -31,8 +33,8 @@ const searchTerm = ref("");
3133
const searchSuggestions = ref<string[]>([]);
3234
const searchResults = ref<SongSearchResult[]>([]);
3335
36+
const offset = ref(0);
3437
const searched = ref(false);
35-
3638
const isLoading = ref(false);
3739
3840
watchDebounced(
@@ -43,15 +45,40 @@ watchDebounced(
4345
{ debounce: 150, maxWait: 500 },
4446
);
4547
48+
async function fetchResults(term: string, offset: number, signal: AbortSignal): Promise<void> {
49+
for await (const result of musicPlayer.services.searchSongs(term, offset, { signal })) {
50+
searchResults.value.push(result);
51+
}
52+
}
53+
54+
let controller = new AbortController();
4655
async function searchFor(term: string): Promise<void> {
56+
// Hide keyboard on mobile after searching
57+
const { activeElement } = document;
58+
if (activeElement instanceof HTMLElement) {
59+
activeElement.blur();
60+
}
61+
4762
if (!term) return;
4863
64+
searchTerm.value = term;
65+
4966
isLoading.value = true;
50-
searchResults.value = await musicPlayer.services.searchSongs(term);
67+
searchResults.value = [];
68+
offset.value = 0;
69+
controller.abort();
70+
controller = new AbortController();
71+
await fetchResults(term, 0, controller.signal);
72+
5173
isLoading.value = false;
5274
searched.value = true;
5375
}
5476
77+
async function loadMoreContent(event: InfiniteScrollCustomEvent): Promise<void> {
78+
await fetchResults(searchTerm.value, ++offset.value, controller.signal);
79+
await event.target.complete();
80+
}
81+
5582
async function playNow(searchResult: SongSearchResult<AnySong>): Promise<void> {
5683
const song = await musicPlayer.services.getSongFromSearchResult(searchResult);
5784
await musicPlayer.state.addToQueue(song, musicPlayer.state.queueIndex);
@@ -76,8 +103,8 @@ function goToSong(searchResult: SongSearchResult): void {
76103

77104
<template>
78105
<AppPage title="Search">
79-
<ion-header id="search" translucent>
80-
<ion-toolbar>
106+
<template #header-trailing>
107+
<ion-toolbar class="searchbar">
81108
<ion-searchbar
82109
:debounce="50"
83110
v-model="searchTerm"
@@ -86,10 +113,10 @@ function goToSong(searchResult: SongSearchResult): void {
86113
inputmode="search"
87114
enterkeyhint="search"
88115
@ion-input="searched = false"
89-
@ion-change="searchFor(<string>$event.detail.value)"
116+
@keydown.enter="searchFor(searchTerm)"
90117
/>
91118
</ion-toolbar>
92-
</ion-header>
119+
</template>
93120

94121
<div>
95122
<ion-list v-if="!(searched || isLoading)" id="search-suggestions">
@@ -108,9 +135,6 @@ function goToSong(searchResult: SongSearchResult): void {
108135
</ion-item>
109136
</ion-list>
110137

111-
<ion-list v-if="isLoading">
112-
<SkeletonItem v-for="i in 25" :key="i" />
113-
</ion-list>
114138
<ion-list v-else id="search-song-items">
115139
<GenericSongItem
116140
v-for="(searchResult, i) of searchResults"
@@ -140,6 +164,13 @@ function goToSong(searchResult: SongSearchResult): void {
140164
</template>
141165
</GenericSongItem>
142166
</ion-list>
167+
<ion-list v-if="isLoading && searchResults.length < 25">
168+
<SkeletonItem v-for="i in 25 - searchResults.length" :key="i + searchResults.length" />
169+
</ion-list>
170+
171+
<ion-infinite-scroll v-if="searchTerm" @ion-infinite="loadMoreContent">
172+
<ion-infinite-scroll-content loading-spinner="dots" />
173+
</ion-infinite-scroll>
143174
</div>
144175
</AppPage>
145176
</template>
@@ -153,18 +184,23 @@ ion-header:not(#search) {
153184
</style>
154185

155186
<style scoped>
156-
#search {
157-
position: sticky;
158-
top: 0;
187+
.searchbar {
188+
transition: opacity, height, 150ms;
189+
opacity: var(--opacity-scale);
190+
height: calc(var(--min-height) + 8px);
191+
transform-origin: top center;
192+
transform: scaleY(var(--opacity-scale));
193+
}
159194
160-
& > ion-toolbar {
161-
padding-top: 0;
162-
}
195+
.header-collapse-condense-inactive > .searchbar {
196+
height: 0;
163197
}
164198
165-
ion-searchbar {
166-
& ion-icon {
167-
font-size: 0.1em;
199+
:not(.header-collapse-condense-inactive) > .searchbar {
200+
ion-searchbar {
201+
& ion-icon {
202+
font-size: 0.1em;
203+
}
168204
}
169205
}
170206

src/services/Music/LocalMusicService.ts

+13-4
Original file line numberDiff line numberDiff line change
@@ -200,10 +200,14 @@ export class LocalMusicService extends MusicService<LocalSong> {
200200
}
201201

202202
#fuse?: Fuse<LocalSong>;
203-
async handleSearchSongs(term: string, offset: number): Promise<LocalSong[]> {
203+
async *handleSearchSongs(
204+
term: string,
205+
offset: number,
206+
options?: { signal: AbortSignal },
207+
): AsyncGenerator<LocalSong> {
204208
// TODO: Maybe split results in smaller chunks and actually paginate it?
205209
if (offset > 0) {
206-
return [];
210+
return;
207211
}
208212

209213
if (!this.#fuse) {
@@ -216,8 +220,13 @@ export class LocalMusicService extends MusicService<LocalSong> {
216220
}
217221

218222
const results = this.#fuse.search(term);
219-
const songs = results.map((value) => value.item);
220-
return songs;
223+
for (const { item } of results) {
224+
if (options?.signal?.aborted) {
225+
return;
226+
}
227+
228+
yield item;
229+
}
221230
}
222231

223232
handleGetSongFromSearchResult(searchResult: LocalSong): LocalSong {

0 commit comments

Comments
 (0)