インタラクティブな歌詞カードを実装した作例です。
GitHub: https://github.com/TextAliveJp/textalive-app-lyric-sheet
TextAlive App APIについて: https://developer.textalive.jp/app/ サンプルコード一覧: https://developer.textalive.jp/app/examples/
A Pen by M Fathurrohman Mauludin on CodePen.
| <!-- オーバーレイ / Overlay --> | |
| <div id="overlay"> | |
| <p><span class="far"></span>now loading...</p> | |
| </div> | |
| <!-- ヘッダ / Header --> | |
| <div id="header"> | |
| <!-- 再生コントロール / Playback control --> | |
| <div id="control" class="far"> | |
| <a href="#" id="play" class="disabled"></a> | |
| <a href="#" id="stop" class="disabled"></a> | |
| </div> | |
| <!-- アーティストと楽曲の情報 / Artist and song info --> | |
| <div id="meta"> | |
| <div id="artist">artist: <span>-</span></div> | |
| <div id="song">song: <span>-</span></div> | |
| </div> | |
| </div> | |
| <!-- 音源 / Audio souce --> | |
| <div id="media"></div> | |
| <!-- 歌詞 / Lyrics text --> | |
| <div id="lyrics"> | |
| <!-- 文字 / Text --> | |
| <div id="text"></div> | |
| <!-- ビートバー / Beat bar --> | |
| <div id="bar"></div> | |
| </div> | |
| <!-- シークバー --> | |
| <div id="seekbar"> | |
| <div></div> | |
| </div> |
| const { Player, stringToDataUrl } = TextAliveApp; | |
| // TextAlive Player を初期化 | |
| const player = new Player({ | |
| // トークンは https://developer.textalive.jp/profile で取得したものを使う | |
| app: { token: "test" }, | |
| mediaElement: document.querySelector("#media"), | |
| mediaBannerPosition: "bottom right" | |
| // オプション一覧 | |
| // https://developer.textalive.jp/packages/textalive-app-api/interfaces/playeroptions.html | |
| }); | |
| const overlay = document.querySelector("#overlay"); | |
| const bar = document.querySelector("#bar"); | |
| const textContainer = document.querySelector("#text"); | |
| const seekbar = document.querySelector("#seekbar"); | |
| const paintedSeekbar = seekbar.querySelector("div"); | |
| let b, c; | |
| // change song | |
| player.addListener({ | |
| /* APIの準備ができたら呼ばれる */ | |
| onAppReady(app) { | |
| if (app.managed) { | |
| document.querySelector("#control").className = "disabled"; | |
| } | |
| if (!app.songUrl) { | |
| document.querySelector("#media").className = "disabled"; | |
| // SUPERHERO / めろくる | |
| player.createFromSongUrl("https://piapro.jp/t/hZ35/20240130103028", { | |
| video: { | |
| // 音楽地図訂正履歴 | |
| beatId: 4592293, | |
| chordId: 2727635, | |
| repetitiveSegmentId: 2824326, | |
| // 歌詞タイミング訂正履歴: https://textalive.jp/songs/piapro.jp%2Ft%2FxEA7%2F20240202002556 | |
| lyricId: 59415, | |
| lyricDiffId: 13962 | |
| } | |
| }); | |
| } | |
| }, | |
| /* 楽曲が変わったら呼ばれる */ | |
| onAppMediaChange() { | |
| // 画面表示をリセット | |
| overlay.className = ""; | |
| bar.className = ""; | |
| resetChars(); | |
| }, | |
| /* 楽曲情報が取れたら呼ばれる */ | |
| onVideoReady(video) { | |
| // 楽曲情報を表示 | |
| document.querySelector("#artist span").textContent = | |
| player.data.song.artist.name; | |
| document.querySelector("#song span").textContent = player.data.song.name; | |
| // 最後に表示した文字の情報をリセット | |
| c = null; | |
| }, | |
| /* 再生コントロールができるようになったら呼ばれる */ | |
| onTimerReady() { | |
| overlay.className = "disabled"; | |
| document.querySelector("#control > a#play").className = ""; | |
| document.querySelector("#control > a#stop").className = ""; | |
| }, | |
| /* 再生位置の情報が更新されたら呼ばれる */ | |
| onTimeUpdate(position) { | |
| // シークバーの表示を更新 | |
| paintedSeekbar.style.width = `${ | |
| parseInt((position * 1000) / player.video.duration) / 10 | |
| }%`; | |
| // 現在のビート情報を取得 | |
| let beat = player.findBeat(position); | |
| if (b !== beat) { | |
| if (beat) { | |
| requestAnimationFrame(() => { | |
| bar.className = "active"; | |
| requestAnimationFrame(() => { | |
| bar.className = "active beat"; | |
| }); | |
| }); | |
| } | |
| b = beat; | |
| } | |
| // 歌詞情報がなければこれで処理を終わる | |
| if (!player.video.firstChar) { | |
| return; | |
| } | |
| // 巻き戻っていたら歌詞表示をリセットする | |
| if (c && c.startTime > position + 1000) { | |
| resetChars(); | |
| } | |
| // 500ms先に発声される文字を取得 | |
| let current = c || player.video.firstChar; | |
| while (current && current.startTime < position + 500) { | |
| // 新しい文字が発声されようとしている | |
| if (c !== current) { | |
| newChar(current); | |
| c = current; | |
| } | |
| current = current.next; | |
| } | |
| }, | |
| /* 楽曲の再生が始まったら呼ばれる */ | |
| onPlay() { | |
| const a = document.querySelector("#control > a#play"); | |
| while (a.firstChild) a.removeChild(a.firstChild); | |
| a.appendChild(document.createTextNode("\uf28b")); | |
| }, | |
| /* 楽曲の再生が止まったら呼ばれる */ | |
| onPause() { | |
| const a = document.querySelector("#control > a#play"); | |
| while (a.firstChild) a.removeChild(a.firstChild); | |
| a.appendChild(document.createTextNode("\uf144")); | |
| } | |
| }); | |
| /* 再生・一時停止ボタン */ | |
| document.querySelector("#control > a#play").addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| if (player) { | |
| if (player.isPlaying) { | |
| player.requestPause(); | |
| } else { | |
| player.requestPlay(); | |
| } | |
| } | |
| return false; | |
| }); | |
| /* 停止ボタン */ | |
| document.querySelector("#control > a#stop").addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| if (player) { | |
| player.requestStop(); | |
| // 再生を停止したら画面表示をリセットする | |
| bar.className = ""; | |
| resetChars(); | |
| } | |
| return false; | |
| }); | |
| /* シークバー */ | |
| seekbar.addEventListener("click", (e) => { | |
| e.preventDefault(); | |
| if (player) { | |
| player.requestMediaSeek( | |
| (player.video.duration * e.offsetX) / seekbar.clientWidth | |
| ); | |
| } | |
| return false; | |
| }); | |
| /** | |
| * 新しい文字の発声時に呼ばれる | |
| * Called when a new character is being vocalized | |
| */ | |
| function newChar(current) { | |
| // 品詞 (part-of-speech) | |
| // https://developer.textalive.jp/packages/textalive-app-api/interfaces/iword.html#pos | |
| const classes = []; | |
| if ( | |
| current.parent.pos === "N" || | |
| current.parent.pos === "PN" || | |
| current.parent.pos === "X" | |
| ) { | |
| classes.push("noun"); | |
| } | |
| // フレーズの最後の文字か否か | |
| if (current.parent.parent.lastChar === current) { | |
| classes.push("lastChar"); | |
| } | |
| // 英単語の最初か最後の文字か否か | |
| if (current.parent.language === "en") { | |
| if (current.parent.lastChar === current) { | |
| classes.push("lastCharInEnglishWord"); | |
| } else if (current.parent.firstChar === current) { | |
| classes.push("firstCharInEnglishWord"); | |
| } | |
| } | |
| // noun, lastChar クラスを必要に応じて追加 | |
| const div = document.createElement("div"); | |
| div.appendChild(document.createTextNode(current.text)); | |
| // 文字を画面上に追加 | |
| const container = document.createElement("div"); | |
| container.className = classes.join(" "); | |
| container.appendChild(div); | |
| container.addEventListener("click", () => { | |
| player.requestMediaSeek(current.startTime); | |
| }); | |
| textContainer.appendChild(container); | |
| } | |
| /** | |
| * 歌詞表示をリセットする | |
| * Reset lyrics view | |
| */ | |
| function resetChars() { | |
| c = null; | |
| while (textContainer.firstChild) | |
| textContainer.removeChild(textContainer.firstChild); | |
| } |
| <script src="https://unpkg.com/axios/dist/axios.min.js"></script> | |
| <script src="https://unpkg.com/textalive-app-api/dist/index.js"></script> |
| /* 背景 / Background */ | |
| body { | |
| background: #c33c68; | |
| background-image: linear-gradient(0deg, #eed475 0, #60a8a9 50%, #d7809e 100%); | |
| background-attachment: fixed; | |
| background-size: 100vw 100vh; | |
| } | |
| /* オーバーレイ / Overlay */ | |
| #overlay { | |
| user-select: none; | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| bottom: 0; | |
| display: flex; | |
| align-items: center; | |
| background: #0006; | |
| color: #fffc; | |
| z-index: 5; | |
| } | |
| #overlay.disabled { | |
| display: none; | |
| } | |
| #overlay > p { | |
| width: 100vw; | |
| font-size: 40px; | |
| text-align: center; | |
| } | |
| #overlay > p > span { | |
| display: inline-block; | |
| padding-right: 20px; | |
| margin-right: 20px; | |
| border-right: 1px solid #fff6; | |
| } | |
| /* フッターと音源 / Header and audio source */ | |
| #header, | |
| #media { | |
| /* ページ左に固定 / Stick to the page left */ | |
| position: fixed; | |
| left: 0; | |
| /* 背景色と文字色 / Background and text color */ | |
| background: #000c; | |
| color: #fff; | |
| z-index: 1; | |
| } | |
| /* フッター / Footer */ | |
| #header { | |
| /* 上寄せ / Top-aligned */ | |
| top: 20px; | |
| /* 少し余裕を持たせる / Box with a small padding */ | |
| padding: 10px 16px; | |
| /* フォントサイズ小さめ、太め / Small but bold typography */ | |
| font-size: 10.5px; | |
| font-weight: bold; | |
| /* 子要素を横に並べて配置 / Align child content to the right */ | |
| display: flex; | |
| /* 子要素は縦に中央揃え / Vertically middle-aligned */ | |
| align-items: center; | |
| } | |
| /* 再生ボタン / Play button */ | |
| #control { | |
| font-size: 21px; | |
| padding-right: 10px; | |
| border-right: 1px solid #fff9; | |
| } | |
| #control.disabled { | |
| display: none; | |
| } | |
| #control a { | |
| color: rgb(99 208 226); | |
| text-decoration: none; | |
| } | |
| #control a.disabled { | |
| opacity: 0.3; | |
| } | |
| #control a:hover { | |
| color: rgb(255 148 56); | |
| } | |
| /* アーティストと楽曲の情報 / Artist and song info */ | |
| #meta { | |
| padding-left: 10px; | |
| } | |
| #meta span { | |
| font-weight: normal; | |
| } | |
| /* 音源 / Audio source */ | |
| #media { | |
| /* 下寄せ / Bottom-aligned */ | |
| bottom: 10px; | |
| } | |
| #media.disabled > .textalive-media-wrapper { | |
| width: 0; | |
| height: 0; | |
| } | |
| /** | |
| * ビート情報が取れるようになったらビートバーを表示 | |
| * Show beat bar when beat information becomes available | |
| */ | |
| @keyframes activateBeatBar { | |
| 0% { | |
| opacity: 0; | |
| } | |
| 100% { | |
| width: 100%; | |
| opacity: 1; | |
| } | |
| } | |
| /** | |
| * ビート毎に右に広げてフェードアウト | |
| * Make beat bar span to the right and then fade out | |
| */ | |
| @keyframes showBeatBar { | |
| 0% { | |
| width: 0; | |
| opacity: 1; | |
| } | |
| 50% { | |
| width: 100%; | |
| opacity: 1; | |
| } | |
| 100% { | |
| width: 100%; | |
| opacity: 0; | |
| } | |
| } | |
| /** | |
| * 歌詞が下からせり出してくる | |
| * Make lyrics text appear from the bottom | |
| */ | |
| @keyframes showLyrics { | |
| 0% { | |
| transform: translate3d(0, 100%, 0); | |
| opacity: 0; | |
| } | |
| 100% { | |
| transform: translate3d(0, 0, 0); | |
| opacity: 1; | |
| } | |
| } | |
| /* ビートバー / Beat bar */ | |
| #bar { | |
| opacity: 0; | |
| height: 3px; | |
| background: rgb(255 222 193); | |
| } | |
| #bar.active { | |
| animation: activateBeatBar 0.3s; | |
| } | |
| #bar.beat { | |
| animation: showBeatBar 0.5s; | |
| } | |
| /* 歌詞 / Lyrics */ | |
| #lyrics { | |
| z-index: 0; | |
| padding: 3em 0 5em 0; | |
| line-height: 2em; | |
| font-size: 36px; | |
| font-family: "Shippori Mincho B1", serif; | |
| color: #e2f8fc; | |
| text-shadow: 2px 2px 3px #e4215a; | |
| user-select: none; | |
| cursor: pointer; | |
| /* 歌詞をちょっと回転させる / Rotate text */ | |
| transform: rotateX(10deg) rotateY(-10deg); | |
| } | |
| #text > div { | |
| /* 文字ごとに改行しない / No line-break per char */ | |
| display: inline-block; | |
| /* 溢れた部分を隠す / Hide overflow content */ | |
| /* overflow: hidden; */ | |
| /* 高さ指定で文字をあえて溢れさす / Make text overflow with height specified */ | |
| /* height: 45px; */ | |
| } | |
| #text > div > div { | |
| animation: showLyrics 0.5s; | |
| } | |
| /** | |
| * 名詞などを強調表示する | |
| * Emphasize nouns | |
| */ | |
| #text .noun { | |
| color: #ddf9ff; | |
| font-size: 40px; | |
| } | |
| /** | |
| * フレーズ終わりで右にマージンを空けて読みやすくする | |
| * Add right margin to the last char in phrases | |
| */ | |
| #text .lastChar { | |
| margin-right: 40px; | |
| } | |
| #text .firstCharInEnglishWord { | |
| margin-left: 20px; | |
| } | |
| #text .lastCharInEnglishWord { | |
| margin-right: 20px; | |
| } | |
| #text .lastCharInEnglishWord + .firstCharInEnglishWord { | |
| margin-left: 0; | |
| } | |
| #seekbar { | |
| position: fixed; | |
| top: 0; | |
| left: 0; | |
| right: 0; | |
| height: 10px; | |
| background: rgba(255 255 255 / 40%); | |
| } | |
| #seekbar > div { | |
| width: 0; | |
| height: 100%; | |
| background: rgba(255 255 255 / 80%); | |
| } |
| <link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho+B1:wght@500&display=swap" rel="stylesheet" /> | |
| <link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.14.0/css/regular.min.css" rel="stylesheet" /> |
インタラクティブな歌詞カードを実装した作例です。
GitHub: https://github.com/TextAliveJp/textalive-app-lyric-sheet
TextAlive App APIについて: https://developer.textalive.jp/app/ サンプルコード一覧: https://developer.textalive.jp/app/examples/
A Pen by M Fathurrohman Mauludin on CodePen.