🌊

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

に公開

1日1zennとして、先週は日々つまづいたことを記事にしてみましたが、
試しに1日1UIをやってみようと思います。

まずは習慣化のために小さい一部分から始めて、徐々に大きなものを早く正確に作れるようにしていきたいです。

今回作るもの

まずは小さく、普段使っているSpotifyの再生トーストを作ってみます

この部分。

要件

バックエンドは作らず、mock的に作ったデータから受け取る形にします。

要素と挙動

  • トーストの色
    • 今回は適当に決めちゃう
  • アルバムのジャケ写(アイコン)
    • モックデータから受け取る
    • タップ後の挙動は一旦作らない
  • アーティスト名
    • モックデータから受け取る
    • タップ後の挙動は一旦作らない
  • 曲名
    • モックデータから受け取る
    • タップ後の挙動は一旦作らない
  • 再生ボタン
    • isPlayingがtrueだと停止ボタン、falseだと再生ボタン
    • タップするとhooksを呼び出し、isPlayingを切り替える
  • スクロールバー
    • モックデータ

モックデータ

なんかモックデータ作ろうと思ってSpotifyいじったら良さげな曲見つかって嬉しい
https://open.spotify.com/intl-ja/track/04hMa15DW5wmC4Q2IV8tWa?si=38f76e20936f4e79
ピアノに限らずだけど楽器感あるトラックが好きなんだ。。

それは置いておいて、開発者モードで色々みてたら最近ハマってる曲があったので、それを使うことにします。

mockTrack.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"
    },
]

ここから構造取り出すのは大分複雑だぞ。。
まあそれは置いておいて、作ります。

逐一思ったことめも

最近CSSをいじってなかったせいでめっちゃ忘れてます。

  • ToastLayoutを真ん中に入りするには、display: flexjustify-content: centerを親要素で定義する
    • align-itemsは垂直方向
  • ToastLayoutを下から60pxにするには、親要素をposition: relativeにしてToastLayoutをbottom: 60pxみたいに指定する。
    • margin-bottomではない
  • 要素を一旦横に並べた後で整えていこう。display:flex;にする。

スクロールバーで出たエラー

Error: React does not recognize the `playingMilliseconds` prop on a DOM element. If you intentionally want it to appear in the DOM as a custom attribute, spell it as lowercase `playingmilliseconds` instead. If you accidentally passed it from a parent component, remove it from the DOM element.

コードは以下

//省略
return ( 
                <ProgressBarLayout>
                    <ProgressBar playingMilliseconds={playingMilliseconds} />
                </ProgressBarLayout>
)

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 | undefined }>`
    width: ${(props) => props.playingMilliseconds ? `${props.playingMilliseconds}px` : `0px`};
    background-color: #FFF;
    height: 4px;
    z-index: 100;
    opacity: 1;
    max-width: 100%;
`

以下にあるやつは試した
https://zenn.dev/lilac/articles/7c235a1841a8da
https://qiita.com/course_k/items/2fb57048a95b035d0a5d
https://qiita.com/NeGI1009/items/6199725a2000c081711b

ドキュメントも読んでるが、うーん。普通に書き方あってそうなんだけどなぁ
https://styled-components.com/docs/basics#adapting-based-on-props

以下を読んでPropsを小文字にしたら治った
https://qiita.com/TK_WebSE/items/065193d5b25534d20462

だが、結局スクロールバーは表示できてない。

とりあえず進捗

page.tsx

ルートコンポーネント。
必要な引数を渡してる。

page.tsx
"use client";

import Image from "next/image";
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 { useHandlePlay } from './hooks';

interface Props {
    uid: string
    isPlaying: boolean
    setIsPlaying: Dispatch<SetStateAction<boolean>>   
}

export const SpotifyToast: FC<Props> = ({
    uid,
    isPlaying,
    setIsPlaying
}) => {
    const [playingMilliseconds, setPlayingMilliseconds] = useState<number | undefined>(0)

    // 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
    const currentTime = 25900
    // const playingMilliseconds = 97
    
    //useEffectとかで呼ばないと無限レンダリングになる
    useEffect(() => {
        if(totalMilliseconds && currentTime) {
            setPlayingMilliseconds(currentTime/totalMilliseconds)
        }
    }, [totalMilliseconds, currentTime])

    const handleClickPlayButton = () => {
        useHandlePlay(isPlaying, setIsPlaying)
    }

    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 playingmilliseconds={playingMilliseconds} />
                </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-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: ${(props) => props.playingmilliseconds ? `${props.playingmilliseconds}px` : `0px`};
    // ${({ playingMilliseconds }) => `width: ${playingMilliseconds}`};
    background-color: #FFF;
    height: 4px;
    z-index: 100;
    opacity: 1;
    max-width: 100%;
`

hooks.ts

再生ボタン押下で「再生」「停止」を出し分けるようにした

hooks.ts
import { Dispatch, SetStateAction, useCallback } from "react"


export const useHandlePlay = (
    isPlaying: boolean,
    setIsPlaying: Dispatch<SetStateAction<boolean>>
) => {
    if(isPlaying) {setIsPlaying(false)}
    else {setIsPlaying(true)}
}

//以下みたいにcallbackにすると最初にクリックされたときのstateで固定されるから、2回目以降ボタンが変わらない

// export const useHandlePlay = (
//     isPlaying: boolean,
//     setIsPlaying: Dispatch<SetStateAction<boolean>>
// ) => {
//     return useCallback(()=> {
//         if(isPlaying) {setIsPlaying(false)}
//         else {setIsPlaying(true)}
//     }, [])
// }

mock.ts

実際のSpotifyのデータ

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"
    },
]

今日の学び

  • ネストされたデータの取り出し方
    • 久々にやった
    • 実際にもGETしたやつに同じ処理をする感じなので基礎練にちょうどよかった感じ
  • CSSの復習
    • justify-contentとか何も見ないと思い出せなかった
    • 何も見ないでわかっていきたい
  • styled-componentについて
    • 深掘り余地あるので明日ちゃんとやる

明日やること

  • スクロールバーの解決
    • props問題じゃなくて普通にCSSミスってる気がしてきた
  • isPlayingがtrueだったら再生秒数を経過させてスクロールバーにも反映させる挙動作る
  • styled-componentちゃんと勉強する
  • useEffectをhooksに切り出す?とかテストしやすい書き方考える
  • テストコード書く

Discussion