Skip to content

Commit 8409494

Browse files
authored
Merge pull request #12 from SevenOutman/fix/unplayable-loop
Avoid infinite auto-skipping when all songs are unplayable
2 parents 75317b1 + a0eaf05 commit 8409494

File tree

7 files changed

+195
-11
lines changed

7 files changed

+195
-11
lines changed

.codesandbox/ci.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"node": "16",
3+
"sandboxes": ["aplayer-react-demo-7kxnb0"]
4+
}

.github/workflows/ci.yml

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ jobs:
2626
with:
2727
node-version: ${{ matrix.node-version }}
2828

29+
- uses: pnpm/action-setup@v2
30+
name: Install pnpm
31+
id: pnpm-install
32+
with:
33+
version: 7
34+
2935
- name: Cache ~/.pnpm-store
3036
uses: actions/cache@v2
3137
env:
@@ -38,9 +44,6 @@ jobs:
3844
${{ runner.os }}-${{ matrix.node-version }}-test-
3945
${{ runner.os }}-
4046
41-
- name: Install pnpm
42-
run: npm i -g pnpm
43-
4447
- name: Install deps
4548
run: pnpm i
4649

@@ -62,6 +65,13 @@ jobs:
6265
- uses: actions/setup-node@v3
6366
with:
6467
node-version: 18.x
68+
69+
- uses: pnpm/action-setup@v2
70+
name: Install pnpm
71+
id: pnpm-install
72+
with:
73+
version: 7
74+
6575
- name: Cache ~/.pnpm-store
6676
uses: actions/cache@v2
6777
env:
@@ -73,7 +83,6 @@ jobs:
7383
${{ runner.os }}-${{ matrix.node-version }}-release-${{ env.cache-name }}-
7484
${{ runner.os }}-${{ matrix.node-version }}-release-
7585
${{ runner.os }}-
76-
- run: npm i -g pnpm
7786
- run: pnpm i
7887
- run: pnpm dlx semantic-release
7988
env:

src/components/player.tsx

Lines changed: 52 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useRef, useState } from "react";
1+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
22
import cx from "clsx";
33

44
import { ReactComponent as IconPlay } from "../assets/play.svg";
@@ -11,6 +11,8 @@ import { defaultThemeColor } from "../constants";
1111
import { PlaylistLoop, PlaylistOrder, usePlaylist } from "../hooks/usePlaylist";
1212
import { Lyrics } from "./lyrics";
1313
import { useThemeColor } from "../hooks/useThemeColor";
14+
import { useNotice } from "../hooks/useNotice";
15+
import { useSetTimeout } from "../hooks/useSetTimeout";
1416

1517
/**
1618
* @see https://aplayer.js.org/#/home?id=options
@@ -54,13 +56,36 @@ export function APlayer({
5456
getSongId: (song) => song.url,
5557
});
5658

59+
const [notice, showNotice] = useNotice();
60+
61+
const autoSkipTimeoutRef = useRef<
62+
ReturnType<typeof setTimeout> | undefined
63+
>();
64+
const setTimeout = useSetTimeout();
65+
66+
const cancelAutoSkip = useCallback(() => {
67+
if (autoSkipTimeoutRef.current) {
68+
clearTimeout(autoSkipTimeoutRef.current);
69+
autoSkipTimeoutRef.current = undefined;
70+
}
71+
}, []);
72+
5773
const audioControl = useAudioControl({
5874
src: playlist.currentSong.url,
5975
initialVolume: volume,
6076
autoPlay,
61-
onError() {
77+
onError(e) {
78+
const { error } = e.target as HTMLAudioElement;
79+
80+
if (error) {
81+
showNotice(
82+
"An audio error has occurred, player will skip forward in 2 seconds."
83+
);
84+
}
6285
if (playlist.hasNextSong) {
63-
playlist.next();
86+
autoSkipTimeoutRef.current = setTimeout(() => {
87+
playlist.next();
88+
}, 2000);
6489
}
6590
},
6691
onEnded() {
@@ -88,11 +113,29 @@ export function APlayer({
88113
}
89114
}, [playlist.currentSong, audioControl.playAudio]);
90115

116+
const handlePlayButtonClick = useCallback(() => {
117+
cancelAutoSkip();
118+
audioControl.togglePlay(playlist.currentSong.url);
119+
}, [audioControl, cancelAutoSkip, playlist.currentSong.url]);
120+
91121
const hasPlaylist = playlist.length > 1;
92122

93123
const [isPlaylistOpen, setPlaylistOpen] = useState(() => hasPlaylist);
94124

95125
const themeColor = useThemeColor(playlist.currentSong, theme);
126+
const playlistAudioProp = useMemo(
127+
() => (Array.isArray(audio) ? audio : [audio]),
128+
[audio]
129+
);
130+
131+
const { prioritize } = playlist;
132+
const handlePlayAudioFromList = useCallback(
133+
(audioInfo: AudioInfo) => {
134+
cancelAutoSkip();
135+
prioritize(audioInfo);
136+
},
137+
[cancelAutoSkip, prioritize]
138+
);
96139

97140
return (
98141
<div
@@ -114,7 +157,7 @@ export function APlayer({
114157
"aplayer-button",
115158
audioControl.isPlaying ? "aplayer-pause" : "aplayer-play"
116159
)}
117-
onClick={() => audioControl.togglePlay(playlist.currentSong.url)}
160+
onClick={handlePlayButtonClick}
118161
>
119162
{audioControl.isPlaying ? <IconPause /> : <IconPlay />}
120163
</div>
@@ -150,16 +193,18 @@ export function APlayer({
150193
onLoopChange={playlist.setLoop}
151194
/>
152195
</div>
153-
<div className="aplayer-notice"></div>
196+
<div className="aplayer-notice" style={notice.style}>
197+
{notice.text}
198+
</div>
154199
<div className="aplayer-miniswitcher"></div>
155200
</div>
156201
{hasPlaylist ? (
157202
<Playlist
158203
themeColor={themeColor}
159204
open={isPlaylistOpen}
160-
audio={Array.isArray(audio) ? audio : [audio]}
205+
audio={playlistAudioProp}
161206
playingAudioUrl={playlist.currentSong.url}
162-
onPlayAudio={(audioInfo) => playlist.prioritize(audioInfo)}
207+
onPlayAudio={handlePlayAudioFromList}
163208
/>
164209
) : null}
165210
</div>

src/hooks/useNotice.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { expect, test, vi } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
import { useNotice } from "./useNotice";
4+
5+
test("Return empty text and opacity: 0 initially", () => {
6+
const { result } = renderHook(() => useNotice());
7+
8+
expect(result.current[0]).toEqual({ text: "", style: { opacity: 0 } });
9+
});
10+
11+
test("showNotice updates state to according text and opacity: 1", () => {
12+
const { result } = renderHook(() => useNotice());
13+
14+
act(() => {
15+
result.current[1]("A notice");
16+
});
17+
18+
expect(result.current[0]).toEqual({
19+
text: "A notice",
20+
style: { opacity: 1 },
21+
});
22+
});
23+
24+
test("After 2000ms, opacity becomes 0 and text remains", () => {
25+
vi.useFakeTimers();
26+
const { result } = renderHook(() => useNotice());
27+
28+
act(() => {
29+
result.current[1]("A notice");
30+
});
31+
32+
act(() => {
33+
vi.advanceTimersByTime(2000);
34+
});
35+
36+
expect(result.current[0]).toEqual({
37+
text: "A notice",
38+
style: { opacity: 0 },
39+
});
40+
});

src/hooks/useNotice.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import { useCallback, useRef, useState } from "react";
2+
import { useSetTimeout } from "./useSetTimeout";
3+
4+
export function useNotice() {
5+
const timerRef = useRef<ReturnType<typeof setTimeout> | undefined>();
6+
const [notice, setNotice] = useState({ text: "", style: { opacity: 0 } });
7+
8+
const setTimeout = useSetTimeout();
9+
10+
const showNotice = useCallback(
11+
(text: string, duration = 2000) => {
12+
if (timerRef.current) {
13+
clearTimeout(timerRef.current);
14+
}
15+
16+
setNotice({ text, style: { opacity: 1 } });
17+
timerRef.current = setTimeout(() => {
18+
setNotice({ text, style: { opacity: 0 } });
19+
}, duration);
20+
},
21+
[setTimeout]
22+
);
23+
24+
return [notice, showNotice] as const;
25+
}

src/hooks/useSetTimeout.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { vi, test, expect } from "vitest";
2+
import { renderHook, act } from "@testing-library/react";
3+
import { useSetTimeout } from "./useSetTimeout";
4+
5+
test("Returns a function that schedule a callback after specific delay", () => {
6+
vi.useFakeTimers();
7+
8+
const { result } = renderHook(() => useSetTimeout());
9+
const callback = vi.fn();
10+
act(() => {
11+
result.current(callback, 1000);
12+
});
13+
14+
act(() => {
15+
vi.advanceTimersByTime(1000);
16+
});
17+
18+
expect(callback).toHaveBeenCalledOnce();
19+
});
20+
21+
test("Cancels the schedule when component unmounts", () => {
22+
vi.useFakeTimers();
23+
24+
const { result, unmount } = renderHook(() => useSetTimeout());
25+
const callback = vi.fn();
26+
act(() => {
27+
result.current(callback, 1000);
28+
});
29+
30+
unmount();
31+
32+
act(() => {
33+
vi.advanceTimersByTime(1000);
34+
});
35+
36+
expect(callback).not.toHaveBeenCalled();
37+
});

src/hooks/useSetTimeout.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { useCallback, useEffect, useRef } from "react";
2+
3+
export function useSetTimeout() {
4+
const timeoutsRef = useRef<ReturnType<typeof setTimeout>[]>([]);
5+
6+
useEffect(() => {
7+
const timeouts = timeoutsRef.current;
8+
return () => {
9+
for (const timeout of timeouts) {
10+
clearTimeout(timeout);
11+
}
12+
};
13+
}, []);
14+
15+
const safeSetTimeout = useCallback((callback: () => void, ms: number) => {
16+
const timeout = setTimeout(callback, ms);
17+
18+
timeoutsRef.current.push(timeout);
19+
20+
return timeout;
21+
}, []);
22+
23+
return safeSetTimeout;
24+
}

0 commit comments

Comments
 (0)