Skip to content

Commit

Permalink
Add Progress Bar to Song Card (Studio & Marketplace) (#617)
Browse files Browse the repository at this point in the history
* feat: Add progress bar to SongCard

* refactor: Remove extra forward slash on url

* feat: Add test data sources for progress bar test

* alternative implementation for audio progress bar

* minor cleanup

* refactors useHlsJs track song progress approach

* removes errant import

* updates audio.ts audio progress functionality

* fix: Add missing audio progress for sale item

* feat: Progress bar updates on Studio Artist Page

* refactor: Update useCallback structure in hls hook

---------

Co-authored-by: Trevor Scandalios <[email protected]>
  • Loading branch information
dmkirshon and scandycuz authored May 27, 2024
1 parent 5030e20 commit 4dd27ae
Show file tree
Hide file tree
Showing 9 changed files with 134 additions and 24 deletions.
3 changes: 2 additions & 1 deletion apps/marketplace/src/components/Sale.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const Sale: FunctionComponent<SaleProps> = ({ sale, isLoading }) => {
const theme = useTheme();
const router = useRouter();

const { isAudioPlaying, playPauseAudio } = usePlayAudioUrl();
const { audioProgress, isAudioPlaying, playPauseAudio } = usePlayAudioUrl();

const initialFormValues = {
streamTokens: sale ? Math.min(1000, sale.availableBundleQuantity) : 1000,
Expand Down Expand Up @@ -91,6 +91,7 @@ const Sale: FunctionComponent<SaleProps> = ({ sale, isLoading }) => {
>
<Box mb={ [2, 2, 0] } mr={ [0, 0, 5] } width={ [240, 240, 400] }>
<SongCard
audioProgress={ audioProgress }
coverArtUrl={ sale.song.coverArtUrl }
imageDimensions={ 480 }
isLoading={ isLoading }
Expand Down
4 changes: 3 additions & 1 deletion apps/marketplace/src/components/Sales.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ const Sales: FunctionComponent<SalesProps> = ({
noResultsContent = "No songs to display at this time.",
}) => {
const router = useRouter();
const { audioUrl, isAudioPlaying, playPauseAudio } = usePlayAudioUrl();
const { audioProgress, audioUrl, isAudioPlaying, playPauseAudio } =
usePlayAudioUrl();

const handleCardClick = (id: string) => {
router.push(`/sale/${id}`);
Expand Down Expand Up @@ -73,6 +74,7 @@ const Sales: FunctionComponent<SalesProps> = ({
return (
<Grid key={ id } md={ 3 } sm={ 4 } xs={ 6 } item>
<SongCard
audioProgress={ audioProgress }
coverArtUrl={ song.coverArtUrl }
isPlayable={ !!song.clipUrl }
isPlaying={ audioUrl === song.clipUrl && isAudioPlaying }
Expand Down
4 changes: 2 additions & 2 deletions apps/marketplace/src/modules/sale/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export const extendedApi = newmApi.injectEndpoints({

query: (saleId) => ({
method: "GET",
url: `/v1/marketplace/sales/${saleId}`,
url: `v1/marketplace/sales/${saleId}`,
}),

transformResponse: (apiSale: ApiSale) => {
Expand Down Expand Up @@ -71,7 +71,7 @@ export const extendedApi = newmApi.injectEndpoints({
...(statuses ? { statuses: statuses.join(",") } : {}),
...rest,
},
url: "/v1/marketplace/sales",
url: "v1/marketplace/sales",
}),

transformResponse: (apiSales: ReadonlyArray<ApiSale>) => {
Expand Down
6 changes: 3 additions & 3 deletions apps/marketplace/src/temp/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export const mockSales: ReadonlyArray<Sale> = [
costAssetName: "XYZ123",
costPolicyId: "123XYZ",
createdAt: new Date("April 10th, 2024").toDateString(),
id: "3cfb2d02-a320-4385-96d1-1498d8a1df58",
id: "cec4f704-69c4-41fd-807d-47aa2d73f0d2",
maxBundleSize: 1,
song: {
artistId: "3cfb2d02-a320-4385-96d1-1498d8a1df58",
Expand All @@ -60,7 +60,7 @@ export const mockSales: ReadonlyArray<Sale> = [
"https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg",
assetUrl: "https://pool.pm/asset10k9w7tt0khmve76ukggk5vftwcsfh2vtdkxx5p",
clipUrl:
"https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8",
"https://asset1lret95e9jyr8y6ry83x447y6xjjmxgsppluuht.poolpm.nftcdn.io/files/0/?tk=1wCx2KO2FeZz5855Mqqhk6Ymuefgoh0E1gyEItGXtHw",
collaborators: [],
coverArtUrl:
"https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png",
Expand Down Expand Up @@ -99,7 +99,7 @@ export const mockSales: ReadonlyArray<Sale> = [
"https://res.cloudinary.com/newm/image/upload/v1701715430/pzeo4bcivjouksomeggy.jpg",
assetUrl: "https://pool.pm/asset10k9w7tt0khmve76ukggk5vftwcsfh2vtdkxx5p",
clipUrl:
"https://media.garage.newm.io/3cfb2d02-a320-4385-96d1-1498d8a1df58/audio/HLS/audio_output.m3u8",
"https://asset1effvlkkw02m9ft3ymlkfld8mhlq05wc2hal5du.newm.nftcdn.io/files/0?tk=13GWPr-C3hRfdkrfy-adwYVHFwNr4pieLik3GxpRr5s",
collaborators: [],
coverArtUrl:
"https://res.cloudinary.com/newm/image/upload/c_limit,w_4000,h_4000/v1706033133/efpgmcjwk8glctlwfzm8.png",
Expand Down
3 changes: 2 additions & 1 deletion apps/studio/src/pages/home/owners/Songs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const Songs: FunctionComponent = () => {
[]
);

const { playSong, stopSong } = useHlsJs(hlsJsParams);
const { audioProgress, playSong, stopSong } = useHlsJs(hlsJsParams);

/**
* Plays and/or stops the song depending on if it's currently playing or not.
Expand Down Expand Up @@ -160,6 +160,7 @@ const Songs: FunctionComponent = () => {
return (
<Grid key={ song.id } lg={ 3 } md={ 4 } sm={ 4 } xs={ 6 } item>
<SongCard
audioProgress={ audioProgress }
coverArtUrl={ song.coverArtUrl }
isLoading={ isLoading }
isPlayable={ !!song.streamUrl }
Expand Down
33 changes: 29 additions & 4 deletions packages/components/src/lib/SongCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
import { type KeyboardEvent, MouseEvent, useCallback } from "react";
import { Box, IconButton, Stack, Typography, useTheme } from "@mui/material";
import {
Box,
IconButton,
LinearProgress,
Stack,
Typography,
useTheme,
} from "@mui/material";
import { PlayArrow, Stop } from "@mui/icons-material";
import { bgImage } from "@newm-web/assets";
import {
Expand All @@ -12,6 +19,7 @@ import currency from "currency.js";
import SongCardSkeleton from "./skeletons/SongCardSkeleton";

interface SongCardProps {
readonly audioProgress?: number;
readonly coverArtUrl?: string;
readonly imageDimensions?: number;
readonly isLoading?: boolean;
Expand All @@ -27,9 +35,10 @@ interface SongCardProps {
}

const SongCard = ({
imageDimensions = 400,
coverArtUrl,
title,
audioProgress = 0,
imageDimensions = 400,
isLoading = false,
isPlayable,
isPlaying,
onCardClick,
Expand All @@ -38,7 +47,7 @@ const SongCard = ({
priceInNewm,
priceInUsd,
subtitle,
isLoading = false,
title,
}: SongCardProps) => {
const theme = useTheme();

Expand Down Expand Up @@ -149,6 +158,22 @@ const SongCard = ({
</IconButton>
) }
</Box>

{ isPlaying && (
<LinearProgress
color="success"
sx={ {
backgroundColor: theme.colors.grey500,
borderBottomLeftRadius: "4px",
borderBottomRightRadius: "4px",
height: "4px",
marginTop: "-4px",
width: "100%",
} }
value={ audioProgress }
variant="determinate"
/>
) }
</Stack>
<Stack
direction="row"
Expand Down
30 changes: 29 additions & 1 deletion packages/utils/src/lib/audio.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export const usePlayAudioUrl = () => {
const [audio, setAudio] = useState<Howl>();
const [audioUrl, setAudioUrl] = useState<string>();
const [isAudioPlaying, setIsAudioPlaying] = useState<boolean>(false);
const [audioProgress, setAudioProgress] = useState<number>(0);

useEffect(() => {
return () => {
Expand All @@ -17,6 +18,31 @@ export const usePlayAudioUrl = () => {
};
}, [audio]);

useEffect(() => {
const timeoutId = setTimeout(() => {
if (!audio) return;

const prevProgress = audioProgress;
const audioPosition = audio.seek();
const audioDuration = audio.duration();

const currentProgress = audioDuration
? (audioPosition / audioDuration) * 100
: 0;

if (prevProgress !== currentProgress) {
setAudioProgress(currentProgress);
}
}, 250);

if (!audio?.playing()) {
setAudioProgress(0);
clearTimeout(timeoutId);
}

return () => clearTimeout(timeoutId);
}, [audio, audioProgress, isAudioPlaying]);

const playPauseAudio = useCallback(
(src?: string) => {
if (!src) return;
Expand Down Expand Up @@ -66,11 +92,13 @@ export const usePlayAudioUrl = () => {

const result = useMemo(
() => ({
// render a small percentage when just starting to show song is playing
audioProgress: audioProgress < 0.75 ? 0.75 : audioProgress,
audioUrl,
isAudioPlaying,
playPauseAudio,
}),
[audioUrl, isAudioPlaying, playPauseAudio]
[audioUrl, isAudioPlaying, playPauseAudio, audioProgress]
);

return result;
Expand Down
74 changes: 63 additions & 11 deletions packages/utils/src/lib/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ export const useHlsJs = ({
onSongEnded,
}: UseHlsJsParams): UseHlsJsResult => {
const videoRef = useRef<HTMLVideoElement | null>(null);
const [audioProgress, setAudioProgress] = useState(0);

/**
* Calls onPlaySong if it exists.
Expand Down Expand Up @@ -168,22 +169,45 @@ export const useHlsJs = ({
[onSongEnded]
);

/**
* Kicks off a timeout that will update the song progress
* if the song progressed.
*/
const trackSongProgress = useCallback(() => {
return setTimeout(() => {
if (!videoRef.current) return;

const prevProgress = audioProgress;
const currentTime = videoRef.current.currentTime;
const duration = videoRef.current.duration;
const currentProgress = duration ? (currentTime / duration) * 100 : 0;

if (prevProgress !== currentProgress) {
setAudioProgress(currentProgress);
}
}, 250);
}, [audioProgress]);

/**
* Play song using native browser functionality.
*/
const playSongNatively = (song: Song) => {
const playSongNatively = useCallback((song: Song) => {
if (!videoRef.current || !song.streamUrl) return;

videoRef.current.src = song.streamUrl;
videoRef.current.addEventListener("loadedmetadata", () => {
videoRef.current?.play();

videoRef.current.addEventListener("loadedmetadata", async () => {
await videoRef.current?.play();
trackSongProgress();
});
};
// Callback doesn't need to update on trackSongProgress changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

/**
* Play song using HLS library.
*/
const playSongWithHlsJs = (song: Song) => {
const playSongWithHlsJs = useCallback((song: Song) => {
if (!videoRef.current || !song.streamUrl) return;

const hls = new Hls({
Expand All @@ -192,10 +216,17 @@ export const useHlsJs = ({
xhr.withCredentials = true;
},
});

hls.loadSource(song.streamUrl);
hls.attachMedia(videoRef.current);
videoRef.current.play();
};

videoRef.current.addEventListener("loadedmetadata", async () => {
await videoRef.current?.play();
trackSongProgress();
});
// Callback doesn't need to update on trackSongProgress changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);

/**
* Plays song using either native browser or hls.js functionality.
Expand All @@ -211,10 +242,9 @@ export const useHlsJs = ({
}

handlePlaySong(song);

videoRef.current.addEventListener("ended", handleSongEnded);
},
[handlePlaySong, handleSongEnded]
[handlePlaySong, handleSongEnded, playSongNatively, playSongWithHlsJs]
);

/**
Expand All @@ -228,8 +258,8 @@ export const useHlsJs = ({
videoRef.current.removeAttribute("src");
videoRef.current.load();

setAudioProgress(0);
handleStopSong(song);

videoRef.current.removeEventListener("ended", handleSongEnded);
},
[handleStopSong, handleSongEnded]
Expand All @@ -238,7 +268,15 @@ export const useHlsJs = ({
/**
* Memoized playSong and stopSong handlers.
*/
const result = useMemo(() => ({ playSong, stopSong }), [playSong, stopSong]);
const result = useMemo(
() => ({
// render a small percentage when just starting to show song is playing
audioProgress: audioProgress < 0.75 ? 0.75 : audioProgress,
playSong,
stopSong,
}),
[playSong, stopSong, audioProgress]
);

/**
* Create video element and attach ref.
Expand All @@ -248,6 +286,20 @@ export const useHlsJs = ({
videoRef.current = video;
}, []);

/**
* Tracks song progress as it continues to play.
*/
useEffect(() => {
const timeoutId = trackSongProgress();

if (!videoRef.current || videoRef.current.ended) {
setAudioProgress(0);
clearTimeout(timeoutId);
}

return () => clearTimeout(timeoutId);
}, [audioProgress, trackSongProgress]);

/**
* Cleanup
*/
Expand Down
1 change: 1 addition & 0 deletions packages/utils/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export interface UseHlsJsParams {
}

export interface UseHlsJsResult {
readonly audioProgress: number;
readonly playSong: (song: Song) => void;
readonly stopSong: (song?: Song) => void;
}
Expand Down

0 comments on commit 4dd27ae

Please sign in to comment.