人生とは、「途中」をさらけ出すことなのだ
ということで、このjamzIp公式サイトも、盛大に絶賛「途中」中なのです
思いつきで、進化していく(はず)
音楽配信サービスってめっちゃ多い

レコードやCDという物理的な媒体で音楽が流通している時は、とてもシンプルやったのですが
単純に、CD屋さんに並ぶようにしたり、会場で手売りしたり
いまは、音楽がデータになり、
インターネットを介して、スマホやPCでいつでもどこでも聴けるようになっちゃいましたね
しかーーーーし
この配信をしているサービスが
めっちゃ多い
のですよ
配信は、大きく二つに分かれておりまして
①月額の定額を払えば聞き放題(でもダウンロードはできないよー)っていうサブスクリプション(サブスク)ってやつ、もしくはストリーミングって呼んだりする
②1曲、1アルバムごとにお金払ってダウンロードする、ダウンロードっていうやつ(そのままやん)
があって(この考え方であってる、たぶん)
サブスク派、ダウンロード派、
それぞれの好みでいろんなサービスを契約していたり、していなかったり
お気に入りのサービスもあったりで
音楽を配信するとなると、より多くの人に聞いてもらいたいとなると
できる限りたくさんのサービスで配信することに
必然的になってしまったのです
だから、「jamzIpのこの曲の配信を始めました」ってなっても
で、どこで聴けるん?ストリーミングやダウンロード先のリンクは?
ってなるので
シングル・アルバムごとに、その音源の配信先がまとまっていればわかりやすいねー
って、ゴーストが囁きまして
それを、自分のサイトに実装したらええやんとあいなりました
構想5分

このサイトは、フロントエンドはastro.jsで、投稿コンテンツはwordpressで管理するjamstack構成で作っているので(wordpressの管理画面しか使わないイメージ)
wpに新しい投稿タイプつくって、音源の内容に沿ったカスタムフィールドつくって、あとは投稿すれば
ネタはOK

そんでもって、astroから、wpへREST apiでリクエスト送って返ってきた、投稿内容をぐるぐるっと並べてかっちょよくスタイリングすれば…

よしできた!
そこにきて、はたと思ったのです
「試聴」ってどうやって実現しようか?
この時、またゴーストが囁きました
html5は標準で<audio></audio>ってタグがあるから、これで簡単に実現できるのでは?
よしやってみよう、ってやってみたら
<audio controls src="audio/sample.mp3"></audio>
こんな感じね

うん、これで再生できる
でもこのプレーヤーはカッコわるいので controlsをなくすと
音は流れるけど、停止とか早送りとか音量調整できなくなる
だからcontrolsは必要やけど….
ブラウザに組み込まれているプレーヤーなので、できることが限定されてたのです
うーーん、簡単に実装できるけど
試聴やから
・音源の、指定の範囲のみ再生
・終端が来て、ぱつって切れるより、フェードアウトさせたい
組み込みプレイヤーは流石にこういう細かい要望には対応できないのですね
思うようなものが無かったら作ってしまえ
すると、またゴーストが囁きまして
ならば、プレーヤーを自分でつくりなさい、きっと先人が、いい感じのjsライブラリをつくってくれているはず。困った時は先人を頼りにしなさい
ということで探したら
ありました、ありました
Howler.js

Howler.jsは、HTMLでサウンドを簡単に扱う事ができる、オーディオライブラリだそうな
公式のサンプル見たら
import {Howl, Howler} from 'howler';
// Setup the new Howl.
const sound = new Howl({
src: ['sound.webm', 'sound.mp3']
});
// Play the sound.
sound.play();
// Change global volume.
Howler.volume(0.5);
えっこんなんでいけちゃうの!
newしてインスタンス作る時に初期設定して、あとはいろーーんなメソッドが準備されていて
それ使うだけ
っていう、ライブラリ作る時のいつものパターンなのね
ああ、ありがたい、ただただありがたい
先人の苦労の上で、いつも楽をさせていただいいているのです
「自分より後の人に、自分と同じ苦労をさせない」という
世界中の開発者の善意の上で、このライブラリという、国籍を超えた開発者の善意の塊
全て無料で使わせていただけるのです
あーありがたい
というわけで
npm instal howler
これだけで準備OK
先人の作ってくれた土台をつかって、何か作ったら、それがまた、次の人の土台になるようにしたい
そんな気持ちで
つくっていこーー
まず、試聴ボタンをクリックしたら

モーダルウィンドウ(小窓が開いて、背景暗くなってさわれなくなる)
が出てくるようにastroコンポーネントにモーダルウィンドウのvueコンポーネント作って呼び出しまして
そのvueコンポーネントでhowler.jsを読み込んで
いろいろ実装していこーう
ということなのですが
当然
<audio>タグのcontrolerにある部品(再生、一時停止、ボリュームの上げ下げ、早送り)は
全部、howler.js使って、<button>や<input type=”lange”>など基本的なhtmlの部品と連動させて
一個一個つくっていかなかん
ということになるのです
そしたら、なんと!
howler.jsには
「音源ファイルの何秒目から何秒目までを再生」とか
「終点の何秒前からフェードアウト」
という、
今回の試聴で実現した機能は、存在しない
ということがわかったのです!
そっか、そんなに都合よく、こんなマニアックな挙動を実現する機能はないわなー
と、ということで
この二つの機能は、試行錯誤しながら手作りすることになりました
とはいえ、howler.jsあるからだいぶん近道できます
そして….
できたどーー
vueで作った「モーダルウィンドウ試聴プレイヤー 再生範囲指定+自動フェードアウト」コンポーネントの全貌です
<template>
<div class="modal-song-root">
<teleport to="body" v-if="mounted">
<div v-if="isOpen" class="modal-overlay" @click.self="close">
<div class="modal" role="dialog" aria-modal="true">
<header class="modal__header">
<h2 class="modal__title">
<span class="text">{{ title }}</span>
<span class="info">作詞: {{ wrote }} / 作曲: {{ compose }}</span>
</h2>
<button
class="modal__close"
type="button"
@click="close"
aria-label="閉じる"
>
<i class="las la-times"></i>
</button>
</header>
<section class="modal__body">
<div v-if="previewUrl">
<div
class="player"
:class="{ '--disabled': !isReady }"
aria-live="polite"
>
<div
class="player__content"
:style="{ backgroundImage: `url(${imageUrl})` }"
>
<button
class="player__btn"
@click="togglePlay"
:disabled="!isReady"
:aria-label="isPlaying ? '一時停止' : '再生'"
>
{{ isPlaying ? '⏸︎' : '▶︎' }}
</button>
</div>
<div class="player__controls">
<!-- ドラッグ対応の区間内プログレス -->
<div
ref="progressEl"
class="player__progress"
role="slider"
tabindex="0"
:aria-valuemin="0"
:aria-valuemax="segmentDuration"
:aria-valuenow="elapsed"
:aria-label="'プレビュー位置'"
@mousedown="onProgressDown"
@touchstart.passive="onProgressDown"
@click="seekByClick"
@keydown.space.prevent="togglePlay"
@keydown.enter.prevent="togglePlay"
@keydown.left.prevent="nudge(-1)"
@keydown.right.prevent="nudge(1)"
>
<div
class="player__progress__bar"
:style="{ width: progressPct + '%' }"
></div>
</div>
<span class="player__time"
>{{ fmtTime(elapsed) }} /
{{ fmtTime(segmentDuration) }}</span
>
<div class="player__volume">
<label for="vol"><i class="las la-volume-up"></i></label>
<input
id="vol"
class="player__vol"
type="range"
min="0"
max="1"
step="0.01"
:value="volume"
:disabled="!isReady"
@input="onVolume"
/>
</div>
</div>
</div>
</div>
<div class="making" v-else>
<p>試聴できるように制作中です。しばしお待ちくださいね</p>
<img :src="`${siteUrl}images/now_making.png`" alt="" />
</div>
</section>
</div>
</div>
</teleport>
</div>
</template>
<script setup>
import {
ref,
computed,
onMounted,
onBeforeUnmount,
nextTick,
watch,
} from 'vue';
import { Howl } from 'howler';
// Astro 由来属性を継承しない
defineOptions({ inheritAttrs: false });
// --- 状態 ---
const mounted = ref(false);
const isOpen = ref(false);
const isReady = ref(false);
const isPlaying = ref(false);
const isPlayFinished = ref(false);
const songId = ref(null);
const title = ref('');
const wrote = ref('');
const compose = ref('');
const previewUrl = ref('');
const imageUrl = ref('');
const timeStart = ref(0);
const timeEnd = ref(0);
const progressEl = ref(null);
const volume = ref(1);
const elapsed = ref(0); // 区間内経過秒
const segmentStart = ref(0); // 区間開始(秒)
const segmentEnd = ref(0); // 区間終了(秒)
const segmentDuration = ref(0); // = end - start
//--- ビジュアル用 ---
const progressPct = computed(() =>
segmentDuration.value
? Math.min(100, Math.max(0, (elapsed.value / segmentDuration.value) * 100))
: 0,
);
// --- 定数 ---
const DEFAULT_PREVIEW_LEN = 15; // end 未指定時の長さ(秒)
const FADE_OUT_MS = 5000; // フェードアウト時間(ms)
const FADE_SAFETY_MS = 120; // フェード開始の安全マージン(ms)
// --- Howler 関連 ---
let howl = null; // Howl インスタンス
let playingId = null; // 現在再生中の soundId(spriteでもIDが返る)
let progressRaf = 0; // 進捗更新用 RAF
let fadeTimeoutId = 0; // フェード予約用
// --- その他 ---
let dragging = false;
let scrollYCache = 0;
const siteUrl = import.meta.env.SITE_URL || '/';
// --- 開く/閉じる ---
const openFromTarget = (el) => {
songId.value = el.dataset.id || null;
title.value = el.dataset.title || '';
wrote.value = el.dataset.wrote || '';
compose.value = el.dataset.compose || '';
imageUrl.value = el.dataset.imageUrl || '';
previewUrl.value = el.dataset.previewUrl || '';
timeStart.value = el.dataset.timeStart
? parseInt(el.dataset.timeStart, 10)
: 0;
timeEnd.value = el.dataset.timeEnd ? parseInt(el.dataset.timeEnd, 10) : 0;
isReady.value = false;
isPlayFinished.value = false;
lockScroll();
isOpen.value = true;
nextTick(() => {
beginSegmentAutoplay();
});
};
const close = () => {
destroyHowl();
isOpen.value = false;
isPlaying.value = false;
isReady.value = false;
isPlayFinished.value = false;
unlockScroll();
};
// --- イベントデリゲート ---
const onClick = (e) => {
const btn = e.target.closest?.('.js-open-preview');
if (!btn) return;
e.preventDefault();
openFromTarget(btn);
};
const onKeydownDoc = (e) => {
if (e.key === 'Escape' && isOpen.value) {
e.preventDefault();
close();
}
};
// ===== Howler で区間オート再生 =====
const beginSegmentAutoplay = () => {
// 既存を破棄
destroyHowl();
isPlayFinished.value = false;
// 区間を決定
const s = Math.max(0, Number(timeStart.value) || 0);
const eRaw = Number(timeEnd.value) || 0;
const endSec = eRaw > s ? eRaw : s + DEFAULT_PREVIEW_LEN;
segmentStart.value = s;
segmentEnd.value = endSec;
segmentDuration.value = Math.max(0, endSec - s);
elapsed.value = 0;
// Howl作成(WebAudio使用/フェード安定)
howl = new Howl({
src: [previewUrl.value],
html5: false,
preload: true,
volume: volume.value,
sprite: {
seg: [s * 1000, segmentDuration.value * 1000], // [startMs, durMs]
},
onload: () => {
isReady.value = true;
},
onplay: (id) => {
isPlaying.value = true;
playingId = id;
startProgressRaf();
// 区間残りに合わせてフェード予約
const remainMs = (segmentEnd.value - (howl.seek(id) || 0)) * 1000;
scheduleFadeOut(id, remainMs);
},
onpause: () => {
isPlaying.value = false;
stopProgressRaf();
clearScheduledFade();
},
onstop: () => {
isPlaying.value = false;
stopProgressRaf();
clearScheduledFade();
},
onend: () => {
isPlaying.value = false;
stopProgressRaf();
clearScheduledFade();
// 終了時に経過を区間長へ合わせておく
elapsed.value = segmentDuration.value;
isPlayFinished.value = true;
},
});
// 再生開始
playingId = howl.play('seg');
};
// 再生/一時停止
const togglePlay = () => {
if (!howl) return;
if (isPlaying.value) {
howl.pause(playingId);
return;
}
// 区間外なら頭から再生し直し
const pos = howl.seek(playingId) || 0;
if (pos < segmentStart.value || pos >= segmentEnd.value) {
playingId = howl.play('seg');
} else {
howl.play(playingId);
// 再開時はフェード予約を張り直す
const remainMs = (segmentEnd.value - pos) * 1000;
scheduleFadeOut(playingId, remainMs);
}
};
// クリックシーク(区間内の比率→秒換算)
const seekByClick = (e) => {
if (!howl || playingId == null || !segmentDuration.value) return;
const rect = e.currentTarget.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (e.clientX - rect.left) / rect.width));
seekToRatio(ratio);
};
// ドラッグシーク
const onProgressDown = (e) => {
if (!howl || playingId == null || !segmentDuration.value) return;
dragging = true;
const move = (clientX) => {
const rect = progressEl.value.getBoundingClientRect();
const ratio = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width));
seekToRatio(ratio);
};
const onMove = (ev) => move(ev.clientX);
const onTouchMove = (ev) => move(ev.touches[0].clientX);
const up = () => {
dragging = false;
window.removeEventListener('mousemove', onMove);
window.removeEventListener('mouseup', up);
window.removeEventListener('touchmove', onTouchMove);
window.removeEventListener('touchend', up);
};
window.addEventListener('mousemove', onMove);
window.addEventListener('mouseup', up, { once: true });
window.addEventListener('touchmove', onTouchMove, { passive: true });
window.addEventListener('touchend', up, { once: true });
};
const seekToRatio = (ratio) => {
if (!howl || playingId == null || !segmentDuration.value) return;
const targetSec = segmentStart.value + ratio * segmentDuration.value;
howl.seek(targetSec, playingId);
// フェード予約を張り直す
const remainMs = (segmentEnd.value - targetSec) * 1000;
scheduleFadeOut(playingId, remainMs);
};
const nudge = (sec) => {
if (!howl || playingId == null) return;
const cur = (howl.seek(playingId) || 0) + sec;
const clamped = Math.min(segmentEnd.value, Math.max(segmentStart.value, cur));
howl.seek(clamped, playingId);
const remainMs = (segmentEnd.value - clamped) * 1000;
scheduleFadeOut(playingId, remainMs);
};
// 音量
const onVolume = (e) => {
const v = Number(e.target.value);
volume.value = Math.max(0, Math.min(1, v));
if (howl) howl.volume(volume.value, playingId ?? undefined);
};
// 進捗更新(RAF)
const startProgressRaf = () => {
stopProgressRaf();
const tick = () => {
if (!howl || playingId == null) return;
const pos = howl.seek(playingId) || 0; // 秒
// 区間外はクランプ
const clamped = Math.min(
segmentEnd.value,
Math.max(segmentStart.value, pos),
);
elapsed.value = clamped - segmentStart.value;
progressRaf = requestAnimationFrame(tick);
};
progressRaf = requestAnimationFrame(tick);
};
const stopProgressRaf = () => {
if (progressRaf) cancelAnimationFrame(progressRaf);
progressRaf = 0;
};
// フェード予約/実行
const clearScheduledFade = () => {
if (fadeTimeoutId) clearTimeout(fadeTimeoutId);
fadeTimeoutId = 0;
};
const scheduleFadeOut = (id, segmentRemainMs) => {
clearScheduledFade();
const delay = Math.max(0, segmentRemainMs - FADE_OUT_MS - FADE_SAFETY_MS);
fadeTimeoutId = setTimeout(() => {
if (!howl || !isPlaying.value) return;
const from = howl.volume(id); // 現在音量を取得
howl.fade(from, 0, FADE_OUT_MS, id);
}, delay);
};
// 終了(保険)
const stopAtEnd = () => {
if (!howl) return;
howl.stop(playingId);
};
// Howler破棄
const destroyHowl = () => {
stopProgressRaf();
clearScheduledFade();
if (howl) {
try {
howl.unload();
} catch {}
}
howl = null;
playingId = null;
};
// --- スクロールロック ---
const lockScroll = () => {
scrollYCache = window.scrollY || window.pageYOffset || 0;
const scrollbar = window.innerWidth - document.documentElement.clientWidth;
const body = document.body;
body.style.position = 'fixed';
body.style.top = `-${scrollYCache}px`;
body.style.left = '0';
body.style.right = '0';
body.style.width = '100%';
if (scrollbar > 0) body.style.paddingRight = `${scrollbar}px`;
body.classList.add('is-modal-open');
};
const unlockScroll = () => {
const body = document.body;
body.style.position = '';
body.style.top = '';
body.style.left = '';
body.style.right = '';
body.style.width = '';
body.style.paddingRight = '';
body.classList.remove('is-modal-open');
window.scrollTo({ top: scrollYCache, left: 0 });
};
// 表示用 mm:ss
const fmtTime = (sec) => {
const s = Number.isFinite(sec) && sec > 0 ? sec : 0;
const m = Math.floor(s / 60);
const ss = Math.floor(s % 60)
.toString()
.padStart(2, '0');
return `${m}:${ss}`;
};
watch(isPlayFinished, (newVal) => {
if (newVal) {
close();
}
});
onMounted(() => {
mounted.value = true;
document.addEventListener('click', onClick);
document.addEventListener('keydown', onKeydownDoc);
});
onBeforeUnmount(() => {
document.removeEventListener('click', onClick);
document.removeEventListener('keydown', onKeydownDoc);
destroyHowl();
});
</script>
<style lang="scss" scoped>
@keyframes modal-fade-in {
from {
opacity: 0;
transform: translateY(30px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
justify-content: center;
align-items: flex-start;
padding-top: 5vh;
z-index: 9999;
}
.modal {
width: min(620px, 92vw);
background: #fff;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
overflow: auto;
animation: modal-fade-in 0.35s ease;
&__header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem 1.25rem;
border-bottom: 1px solid #eee;
}
&__title {
display: flex;
flex-direction: column;
margin: 0;
.text {
font-size: 1.4rem;
font-family: 'Shippori Mincho', serif;
}
.info {
font-size: 0.9rem;
color: #666;
}
}
&__close {
font-size: 1.3rem;
background: transparent;
border: 0;
cursor: pointer;
}
&__body {
}
}
.meta {
color: #666;
margin-bottom: 0.75rem;
}
.player {
display: flex;
flex-direction: column;
align-items: center;
user-select: none;
&__content {
width: 100%;
height: 42vh;
max-height: 620px;
margin-left: auto;
margin-right: auto;
background-repeat: no-repeat;
background-size: cover;
background-position: center;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
position: relative;
@include g.responsive(md) {
height: 50vh;
}
@include g.responsive(lg) {
height: 40vh;
}
}
&__controls {
padding-top: 0.75rem;
padding-bottom: 0.5rem;
padding-left: 1rem;
padding-right: 1rem;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
width: 100%;
flex-grow: 1;
min-width: 0; /* Firefox 対策 */
background-color: #000;
}
&__volume {
display: flex;
align-items: center;
width: calc(40% - 10px);
margin-left: 10px;
i {
font-size: 1.5rem;
text-align: center;
color: #fff;
}
}
&.--disabled {
opacity: 0.6;
pointer-events: none;
}
&__btn {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 32px;
height: 32px;
font-size: 1.5rem;
display: inline-flex;
justify-content: center;
align-items: center;
border: 1px solid #ddd;
border-radius: 0.5rem;
background: rgba(255, 255, 255, 0.8);
cursor: pointer;
@include g.responsive(md) {
width: 50px;
height: 50px;
font-size: 2rem;
}
&:hover {
background: #f7f7f7;
}
}
&__progress {
position: relative;
width: calc(40% - 10px);
height: 10px;
background: #fff;
border-radius: 6px;
cursor: pointer;
outline: none;
&:focus {
box-shadow: 0 0 0 2px rgba(0, 0, 0, 0.15) inset;
}
}
&__progress__bar {
position: absolute;
inset: 0 auto 0 0;
width: 0%;
background: #999;
border-radius: 6px;
}
&__time {
font-variant-numeric: tabular-nums;
color: #fff;
text-align: right;
font-size: 0.8rem;
margin-left: 10px;
}
&__vol {
width: 100%;
cursor: pointer;
}
}
.making {
margin-top: 1rem;
text-align: center;
padding: 2rem;
p {
font-size: 1.125rem;
margin-bottom: 1rem;
color: #666;
}
img {
height: auto;
opacity: 0.7;
}
}
:global(body.is-modal-open) {
overflow: hidden;
overscroll-behavior: contain;
}
</style>
スイリングまではいいかな、と思ったけど、貼り付けちゃう
これが実際動作しているのが
例えばこのページです
https://www.jamzip.com/music/3276
vueとhowler.jsを使って、音楽プレイヤーを実装したい人が後世にいらっしゃれば
何らかのヒントになれば幸いでございます
名もなき先人の偉大な肩の上に乗って、ちょこちょこっとやってるだけ
ほんま、それだけなのです
ライブラリの中で、コードとして書かれている文字は
間違いなく、誰かの指を通じてキーボードで一文字一文字打ち込まれたわけで
その行いを通じ、その人が捧げた人生の時間を
僕らは、有り難く使わせてもろてるのですね
そうやって、過去から未来へ開発者はずっと繋がっているのです
コードを書いていると
そうやって、自然と「保守的」な考えになるのですね
「保守」というのは言葉の通り
「保って守る」先人へのリスペクトなのですね
先人の到達点の上に立って
今、目の前にある問題をどう解決するか
これが本質的な「保守」つまりconservativeではなくてmaintenanceなのではなかろうか
なんてことを
思いつくままに書いてしまった