【1日1zenn - day7】Spotifyの再生トースト風UIを作ってみる - 2
以下の記事の続きです。
1日1UI。
習慣化のために小さい一部分から始めて、徐々に大きなものを早く正確に作れるようにしていきたいです。
まずは小さく、普段使っているSpotifyの再生トーストを作ってみます
今日やりたいこと
- スクロールバーの解決
- props問題じゃなくて普通にCSSミスってる気がしてきた
- isPlayingがtrueだったら再生秒数を経過させてスクロールバーにも反映させる挙動作る
- styled-componentちゃんと勉強する
- useEffectをhooksに切り出す?とかテストしやすい書き方考える
- テストコード書く
- やれそうだったらモーダルが立ち上がる挙動とかもやりたい
やりながらメモってく
スクロールバーの解決
今は見た目には反映されてないというか、親コンポーネントの色がプログレスバーに効いちゃってそう?
const ProgressBarLayout = styled.div`
background-color: #FFF;
opacity: 0.5;
height: 4px;
margin-top: 4px;
margin-left: 4px;
margin-right: 4px;
z-index: 1;
max-width: 100%;
`
const ProgressBar = styled.div<{ playingmilliseconds: number; }>`
width: 90%; //HTMLには反映されるが見た目には出ない
// width: ${(props) => props.playingmilliseconds ? `${props.playingmilliseconds}%` : `0%`};
// ${({ playingMilliseconds }) => `width: ${playingMilliseconds}`};
background-color: #FFF;
height: 4px;
z-index: 100;
opacity: 1;
max-width: 100%;
`
試しに親コンポーネントの色を変えたら正しく親だけに反映されて、opacityを消したら子のopacityも消えた。
const ProgressBarLayout = styled.div`
background-color: #000;
// opacity: 0.5;
height: 4px;
margin-top: 4px;
margin-left: 4px;
margin-right: 4px;
z-index: 1;
max-width: 100%;
`
const ProgressBar = styled.div<{ playingmilliseconds: number; }>`
width: 90%; //HTMLには反映されるが見た目には出ない
// width: ${(props) => props.playingmilliseconds ? `${props.playingmilliseconds}%` : `0%`};
// ${({ playingMilliseconds }) => `width: ${playingMilliseconds}`};
background-color: #FFF;
height: 4px;
z-index: 100;
opacity: 1;
max-width: 100%;
`
よってopacityは多分要素全体にかかっちゃうのかな。となるとrgbでopacity指定する書き方したら治るかも。
そう思って調べてみると、、
opacity の値は子要素に継承されませんが、要素のコンテンツを含む全体に適用されます。すなわち、ある要素とその子の不透明度が互いに異なっていたとしても、その要素の背景に対してはすべて同じ不透明度になります。
https://developer.mozilla.org/ja/docs/Web/CSS/opacity
とのこと。
横着してカラーコードで指定したのが良くなかったなぁ。ちょっとRGB形式でフィーリングで色作れるように今度試そう。
とりあえず見た目は治った
再生中は秒数を経過させてスクロールバーに反映させる
今はルートで定義したuseStateで再生状態を管理しています
export default function Home() {
const [isPlaying, setIsPlaying] = useState<boolean>(false)
return (
<div className={styles.page}>
<main className={styles.main}>
<SpotifyToast
uid="34316435653538306639663933353466"
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
/>
</main>
</div>
);
}
これがisPlayingだったらcurrentTimeを加算していきたいけど、アクションではなく常時系はどう定義づけるんだろう。調べる。
これとか参考になりそう
と思ったら、setInterval
はクセモノらしい。
んーここではuseEffectで紹介されているけど、useCallbackで関数作って呼び出すのでもいいんじゃないかな。正直違いがあまりわかっていないので勉強しなきゃ。する。
まずこれ
useEffect(実行する関数, [着目したい値]);
const 関数名 = useCallback(実行する関数, [着目したい値]);
どちらの機能も一言で表すと"着目している値が変化した時に関数を実行させる"機能です。
今回のように、バックエンドからデータ一覧を取得して表示する際にはuseEffectとuseCallback、どちらを取得するのが良いのでしょうか?
着目する点は、useCallbackは再レンダリングによる不必要な関数生成を防ぐために使うという点です。
今回は不必要に何度も呼ばれてしまう関数はありません。
そのため、useEffectの使用が適切です。
なるほど、わかりやすい。不必要に何度も呼ばれる関数があるならUseCallbackにすべき。そうじゃないならuseEffectで良さそう。
しかしこんな記事もある
なるほど、要はuseEffect内で変化する値を第二引数である「着目したい値」にした場合は無限レンダリングが走る。マウントされる度にuseEffectが実行されるけど、さらにuseEffectが実行された結果マウントされるようになってると無限になる感じ。それはそうだ。前提『副作用』はDOMやAPIの通信、state/propsの変更など、関数の外に影響を与えること。
この例だとuseStateとかにはせず、生の変数で書いてた。確かになんかReact使ってるととりあえずstateに保持したくなる気持ちがあるので、不要かどうか判断しよう。
これも結構わかりやすいが、後でちゃんと読もう。
そして今回は?
DOMに影響を与えたいので副作用ではある。useEffectは初回レンダリングだけ実行したい時に第二引数を空配列にして使う印象があるが、今回はそうではない。
useCallbackも調べるか。
useCallback、あんま考えず使っていたけど、onClickとかで毎回作動する関数がレンダリングの度に新しく作り直されないようにする感じなのか。言葉としては知っていたが、腹落ちした気がする。
DOMに直接アクセスするときにinputRef、とかはわかるが、『useStateを使うと再レンダリングが発生してしまうため、useRefを使用するのが適している』とかはあんま意識してなかった。
jotaiとかも使ってるじゃん。んーーー使った方がいい印象は持ちつつあった。
そして多分react-timer-hook
のuseStapWatchで秒数を管理してそう。
そうしようかな。
react-timer-hookで実装した結果
こんな感じ。gifってめちゃ遅く見えるんだな、実際は本当の秒数に近い感じで、再生と停止でちゃんとプログレスバーが進むようになりました。
写真やタイトルはJSONから取り出してるし、カウンターも実際はWebSocketとかでリアルタイム通信した値を入れているだけだろうが、それでもいい感じだ。
そして合計秒数が楽曲の秒数を超えたらリセットして最初から繰り返すようにもしました。
クソコード多いのと、テストコード書く前に力尽きたというアレがありますが、一旦以下みたいな感じです。
page.tsx
"use client";
import styles from "./page.module.css";
import { SpotifyToast } from "./features/spotifyToast";
import { useState } from "react";
export default function Home() {
const [isPlaying, setIsPlaying] = useState<boolean>(false)
return (
<div className={styles.page}>
<main className={styles.main}>
<SpotifyToast
uid="34316435653538306639663933353466"
isPlaying={isPlaying}
setIsPlaying={setIsPlaying}
/>
</main>
</div>
);
}
index.tsx
"use client";
import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react';
import styled from 'styled-components'
import { mockTracks } from './mock';
import { useHandlePlayButton, useHandleSongEnd } from './hooks';
import { useStopwatch } from 'react-timer-hook';
interface Props {
uid: string
isPlaying: boolean
setIsPlaying: Dispatch<SetStateAction<boolean>>
}
export const SpotifyToast: FC<Props> = ({
uid,
isPlaying,
setIsPlaying
}) => {
const [progressBarValue, setProgressBarValue] = useState<number | undefined>(0)
const {
start,
pause,
reset,
totalSeconds,
} = useStopwatch({ autoStart: false });
// uidが一致するものをfindする
const track = mockTracks.find(item => item.uid === uid)
// 超ネストしてる時の取り出し方。多分呼び出し側の条件によって[1]とか変えてる?でも区別しやすいデータになってなさそう
const imageSrc = track?.itemV2?.data?.albumOfTrack?.coverArt?.sources?.[1]?.url
const imageHeight = track?.itemV2?.data?.albumOfTrack?.coverArt?.sources?.[1]?.height
const imageWidth = track?.itemV2?.data?.albumOfTrack?.coverArt?.sources?.[1]?.width
const artistsName = track?.itemV2?.data?.albumOfTrack?.artists?.items?.[0]?.profile?.name
const songName = track?.itemV2?.data?.albumOfTrack?.name
const totalMilliseconds = track?.itemV2?.data?.trackDuration?.totalMilliseconds
//useEffectとかで呼ばないと無限レンダリングになる
useEffect(() => {
if(totalMilliseconds && totalSeconds) {
setProgressBarValue(totalSeconds * 1000 /totalMilliseconds * 100)
}
console.log(progressBarValue)
}, [totalMilliseconds, totalSeconds])
const handleClickPlayButton = () => {
useHandlePlayButton(
isPlaying,
setIsPlaying,
totalMilliseconds,
start,
pause,
totalSeconds,
)
}
useHandleSongEnd(
reset,
totalMilliseconds,
totalSeconds,
)
return (
<Root>
<ToastLayout>
<ContentLayout>
<Image
src={imageSrc}
height={imageHeight}
width={imageWidth}
/>
<NamesLayout>
<SongName>
<div>{songName}</div>
</SongName>
<ArtistsName>
<div>{artistsName}</div>
</ArtistsName>
</NamesLayout>
<PlayButton onClick={handleClickPlayButton}>
{isPlaying ? (
<div>停止</div>
) : (
<div>再生</div>
)}
</PlayButton>
</ContentLayout>
<ProgressBarLayout>
<ProgressBar progress_bar_value={progressBarValue} />
</ProgressBarLayout>
</ToastLayout>
</Root>
)
}
const Root = styled.div`
background-color: #DDD;
width: 375px;
height: 900px;
display: flex;
justify-content: center;
position: relative;
`;
const ToastLayout = styled.div`
width: 360px;
height: 80px;
background-color: #292980;
position: absolute;
bottom: 60px;
color: #FFF
`;
const ContentLayout = styled.div`
display:flex;
align-items: center;
margin-top: 4px;
`
const Image = styled.img`
margin-left: 4px
`;
const NamesLayout = styled.div`
margin-left: 8px
`
const SongName = styled.div`
color: #FFF;
font-weight: bold;
font-size: 16px;
`
const ArtistsName = styled.div`
color: #FFF;
font-weight: 200;
`
const PlayButton = styled.div`
position: absolute;
right: 20px;
color: #FFF;
`
const ProgressBarLayout = styled.div`
background: rgba(255, 255, 255, 0.3);
height: 4px;
margin-top: 4px;
margin-left: 4px;
margin-right: 4px;
z-index: 1;
max-width: 100%;
`
const ProgressBar = styled.div`
${({ progress_bar_value }) => `width: ${progress_bar_value}%`};
background-color: #FFF;
height: 4px;
z-index: 100;
opacity: 1;
max-width: 100%;
`
hooks.ts
import { Dispatch, SetStateAction } from "react"
export const useHandlePlayButton = (
isPlaying: boolean,
setIsPlaying: Dispatch<SetStateAction<boolean>>,
totalMilliseconds: number | undefined,
start: () => void,
pause: () => void,
totalSeconds: number,
) => {
if (!totalMilliseconds) return
if(isPlaying && totalSeconds <= totalMilliseconds) {
setIsPlaying(false)
pause()
console.log('stop呼び出しとtotalSeconds', totalSeconds)
} else {
setIsPlaying(true)
start()
console.log('start呼び出しとtotalSeconds', totalSeconds)
}
return
}
export const useHandleSongEnd = (
reset: (offsetTimestamp?: Date, autoStart?: boolean) => void,
totalMilliseconds: number | undefined,
totalSeconds: number,
) => {
if (!totalMilliseconds) return
if (totalSeconds * 1000 >= totalMilliseconds) reset(new Date(), true)
console.log('resetのtotalSeconds', totalSeconds)
console.log('resetのtotalMilliseconds', totalMilliseconds)
}
export const mockTracks = [
{
"itemV2": {
"__typename": "TrackResponseWrapper",
"data": {
"__typename": "Track",
"albumOfTrack": {
"artists": {
"items": [
{
"profile": {
"name": "LEX"
},
"uri": "spotify:artist:2KpK4apOMD6evPHoPggSVF"
}
]
},
"coverArt": {
"sources": [
{
"height": 300,
"url": "https://i.scdn.co/image/ab67616d00001e02e2335270dcc4ec17ec855d88",
"width": 300
},
{
"height": 64,
"url": "https://i.scdn.co/image/ab67616d00004851e2335270dcc4ec17ec855d88",
"width": 64
},
{
"height": 640,
"url": "https://i.scdn.co/image/ab67616d0000b273e2335270dcc4ec17ec855d88",
"width": 640
}
]
},
"name": "この世界に国が無かったら",
"uri": "spotify:album:3xew462pPgsDMWwwCC5lut"
},
"artists": {
"items": [
{
"profile": {
"name": "LEX"
},
"uri": "spotify:artist:2KpK4apOMD6evPHoPggSVF"
}
]
},
"associationsV2": {
"totalCount": 0
},
"contentRating": {
"label": "NONE"
},
"discNumber": 1,
"trackDuration": {
"totalMilliseconds": 259704
},
"name": "この世界に国が無かったら",
"playability": {
"playable": true,
"reason": "PLAYABLE"
},
"playcount": "382137",
"trackNumber": 1,
"uri": "spotify:track:129vY7oCXRRzEeb8PETl6H"
}
},
"addedAt": {
"isoString": "1970-01-01T00:00:00Z"
},
"addedBy": null,
"attributes": [
{
"key": "decision_id",
"value": "ssp~062962a939604c7477f2586d1cea26931c51"
}
],
"uid": "34316435653538306639663933353466"
},
]
今日の学び
- AIは一切開かなかった
- 昨日謎に詰まってたプログレスバーも一晩寝かしたら秒で直せた
- 最近よく見かけるエラーは何も調べず一瞬で直せたり、ここ数日の蓄積を感じる。
-
react-timer-hook
を初めて使ってみた- ここに辿り着くまでの過程もいい感じ。色々調べたことで周辺知識がつくのはAIだと実は得にくい。記事だとファクトか憶測か読めばわかることが多いので脳内ハルシネーションも起きにくい
- useEffectやuseCallbackなど、副作用系について少し腹落ちが進んだ
- ただもっとちゃんと理解する必要がありそう
心残り
- Reactのhooksやライフサイクル系、もっと根本理解せねばならない。明日はそれにしようかな。
- テストコードかけてないからやらねば。
- リファクタリングも必要そう。テストするためにuseEffectをindex.tsxで定義しないようにしたいし。
- 普通にアウトプットの質が低いし遅いので、同じ時間でもっと色々作れるようにしたい
- 時間がかかったのは、毎秒プログレスバーを進めるためにどうするか調べた時間という感じなので、useEffectとかの理解が深ければ多分削減できた見込み。
明日やること
- Reactのライフサイクルやuse〇〇系をまとめた上で、その理解を踏まえて(?)テストコード書くつもり
Discussion