🍣

【1日1zenn - day7】Spotifyの再生トースト風UIを作ってみる - 2

2024/12/17に公開

以下の記事の続きです。
https://zenn.dev/shunsuke108m/articles/a1845c81215324

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を加算していきたいけど、アクションではなく常時系はどう定義づけるんだろう。調べる。

これとか参考になりそう
https://zenn.dev/ringotabetai/articles/a25060eb756b0c

と思ったら、setIntervalはクセモノらしい。
https://qiita.com/FumioNonaka/items/587c3ed8545d820f330c
んーここではuseEffectで紹介されているけど、useCallbackで関数作って呼び出すのでもいいんじゃないかな。正直違いがあまりわかっていないので勉強しなきゃ。する。

まずこれ
https://qiita.com/ringo10hunter/items/8ddf890b25b617883484

useEffect(実行する関数, [着目したい値]);
const 関数名 = useCallback(実行する関数, [着目したい値]);
どちらの機能も一言で表すと"着目している値が変化した時に関数を実行させる"機能です。
今回のように、バックエンドからデータ一覧を取得して表示する際にはuseEffectとuseCallback、どちらを取得するのが良いのでしょうか?
着目する点は、useCallbackは再レンダリングによる不必要な関数生成を防ぐために使うという点です。
今回は不必要に何度も呼ばれてしまう関数はありません。
そのため、useEffectの使用が適切です。

なるほど、わかりやすい。不必要に何度も呼ばれる関数があるならUseCallbackにすべき。そうじゃないならuseEffectで良さそう。

しかしこんな記事もある
https://zenn.dev/rinda_1994/articles/6752d2baa7b2d8
なるほど、要はuseEffect内で変化する値を第二引数である「着目したい値」にした場合は無限レンダリングが走る。マウントされる度にuseEffectが実行されるけど、さらにuseEffectが実行された結果マウントされるようになってると無限になる感じ。それはそうだ。

https://qiita.com/Mitsuw0/items/801f783ca74b062c1ed8
前提『副作用』はDOMやAPIの通信、state/propsの変更など、関数の外に影響を与えること。

https://www.cxr-inc.com/blog/cc98228bc2ba48d3853d077f25fb831c
そもそもuseEffectは使わないほうがいいという話。useEffectにするとそのクラス全体のレンダリングが走ったりするから、そうしたい場合を除いて不本意では?という。
この例だとuseStateとかにはせず、生の変数で書いてた。確かになんかReact使ってるととりあえずstateに保持したくなる気持ちがあるので、不要かどうか判断しよう。

https://kinsta.com/jp/knowledgebase/react-useeffect/
これも結構わかりやすいが、後でちゃんと読もう。

そして今回は?
DOMに影響を与えたいので副作用ではある。useEffectは初回レンダリングだけ実行したい時に第二引数を空配列にして使う印象があるが、今回はそうではない。
useCallbackも調べるか。

https://note.com/keyem/n/nf2627315b9d9
useCallback、あんま考えず使っていたけど、onClickとかで毎回作動する関数がレンダリングの度に新しく作り直されないようにする感じなのか。言葉としては知っていたが、腹落ちした気がする。

https://qiita.com/Dragon1208/items/320bf04672050c4dcaeb
useRef系。まだ腹落ちしてないから読み直そう。
DOMに直接アクセスするときにinputRef、とかはわかるが、『useStateを使うと再レンダリングが発生してしまうため、useRefを使用するのが適している』とかはあんま意識してなかった。

https://qiita.com/umiushi_1/items/81e65cbe6d8c6a161449
これgithub見に行ったらめちゃくちゃちゃんと実装してるし挙動もめちゃいい。
jotaiとかも使ってるじゃん。んーーー使った方がいい印象は持ちつつあった。
そして多分react-timer-hookのuseStapWatchで秒数を管理してそう。
そうしようかな。

react-timer-hookで実装した結果


こんな感じ。gifってめちゃ遅く見えるんだな、実際は本当の秒数に近い感じで、再生と停止でちゃんとプログレスバーが進むようになりました。
写真やタイトルはJSONから取り出してるし、カウンターも実際はWebSocketとかでリアルタイム通信した値を入れているだけだろうが、それでもいい感じだ。
そして合計秒数が楽曲の秒数を超えたらリセットして最初から繰り返すようにもしました。

クソコード多いのと、テストコード書く前に力尽きたというアレがありますが、一旦以下みたいな感じです。

page.tsx

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

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

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)
}
mock.ts
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