音楽プレーヤーをつくってみた

Vue.jsで音楽プレイヤーを作ってみた。ライブラリHowler.jsで音声データを操作

makotoのブログアイコン
マコトノトコノマ

人生とは、「途中」をさらけ出すことなのだ
ということで、このjamzIp公式サイトも、盛大に絶賛「途中」中なのです
思いつきで、進化していく(はず)

音楽配信サービスってめっちゃ多い

レコードやCDという物理的な媒体で音楽が流通している時は、とてもシンプルやったのですが
単純に、CD屋さんに並ぶようにしたり、会場で手売りしたり
いまは、音楽がデータになり、
インターネットを介して、スマホやPCでいつでもどこでも聴けるようになっちゃいましたね
しかーーーーし
この配信をしているサービスが
めっちゃ多い

のですよ
配信は、大きく二つに分かれておりまして
①月額の定額を払えば聞き放題(でもダウンロードはできないよー)っていうサブスクリプション(サブスク)ってやつ、もしくはストリーミングって呼んだりする

②1曲、1アルバムごとにお金払ってダウンロードする、ダウンロードっていうやつ(そのままやん)

があって(この考え方であってる、たぶん)
サブスク派、ダウンロード派、
それぞれの好みでいろんなサービスを契約していたり、していなかったり
お気に入りのサービスもあったりで
音楽を配信するとなると、より多くの人に聞いてもらいたいとなると
できる限りたくさんのサービスで配信することに
必然的になってしまったのです

だから、「jamzIpのこの曲の配信を始めました」ってなっても
で、どこで聴けるん?ストリーミングやダウンロード先のリンクは?
ってなるので
シングル・アルバムごとに、その音源の配信先がまとまっていればわかりやすいねー
って、ゴーストが囁きまして

それを、自分のサイトに実装したらええやんとあいなりました
構想5分

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

wpのカスタム投稿をさくっと作って、音源の内容を投稿すればOK

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

背景はアイキャッチを画面最大にして、ぼかすのが今風ですねー

よしできた!

そこにきて、はたと思ったのです
「試聴」ってどうやって実現しようか?

この時、またゴーストが囁きました

html5は標準で<audio></audio>ってタグがあるから、これで簡単に実現できるのでは?

よしやってみよう、ってやってみたら

sample-1 (html)
<audio controls src="audio/sample.mp3"></audio>

こんな感じね

うん、これで再生できる

でもこのプレーヤーはカッコわるいので controlsをなくすと
音は流れるけど、停止とか早送りとか音量調整できなくなる
だからcontrolsは必要やけど….
ブラウザに組み込まれているプレーヤーなので、できることが限定されてたのです
うーーん、簡単に実装できるけど

試聴やから
・音源の、指定の範囲のみ再生
・終端が来て、ぱつって切れるより、フェードアウトさせたい
組み込みプレイヤーは流石にこういう細かい要望には対応できないのですね

思うようなものが無かったら作ってしまえ

すると、またゴーストが囁きまして

ならば、プレーヤーを自分でつくりなさい、きっと先人が、いい感じのjsライブラリをつくってくれているはず。困った時は先人を頼りにしなさい

ということで探したら
ありました、ありました

Howler.js

Howler.jsは、HTMLでサウンドを簡単に扱う事ができる、オーディオライブラリだそうな
公式のサンプル見たら

sample-3 (javascript)
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してインスタンス作る時に初期設定して、あとはいろーーんなメソッドが準備されていて
それ使うだけ
っていう、ライブラリ作る時のいつものパターンなのね
ああ、ありがたい、ただただありがたい

先人の苦労の上で、いつも楽をさせていただいいているのです
「自分より後の人に、自分と同じ苦労をさせない」という
世界中の開発者の善意の上で、このライブラリという、国籍を超えた開発者の善意の塊
全て無料で使わせていただけるのです
あーありがたい
というわけで

sample-5 (plaintext)
npm instal howler

これだけで準備OK
先人の作ってくれた土台をつかって、何か作ったら、それがまた、次の人の土台になるようにしたい
そんな気持ちで
つくっていこーー

まず、試聴ボタンをクリックしたら

モーダルウィンドウ(小窓が開いて、背景暗くなってさわれなくなる)
が出てくるようにastroコンポーネントにモーダルウィンドウのvueコンポーネント作って呼び出しまして
そのvueコンポーネントでhowler.jsを読み込んで
いろいろ実装していこーう
ということなのですが
当然
<audio>タグのcontrolerにある部品(再生、一時停止、ボリュームの上げ下げ、早送り)は
全部、howler.js使って、<button>や<input type=”lange”>など基本的なhtmlの部品と連動させて
一個一個つくっていかなかん
ということになるのです

そしたら、なんと!
howler.jsには
「音源ファイルの何秒目から何秒目までを再生」とか
「終点の何秒前からフェードアウト」
という、
今回の試聴で実現した機能は、存在しない
ということがわかったのです!

そっか、そんなに都合よく、こんなマニアックな挙動を実現する機能はないわなー
と、ということで
この二つの機能は、試行錯誤しながら手作りすることになりました
とはいえ、howler.jsあるからだいぶん近道できます

そして….
できたどーー
vueで作った「モーダルウィンドウ試聴プレイヤー 再生範囲指定+自動フェードアウト」コンポーネントの全貌です

sample-7.vue (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なのではなかろうか
なんてことを
思いつくままに書いてしまった

New Live

11/15 イベントフライヤー画像「お針子かふぇ」オープンイベントシェアカフェ お針子かふぇ [兵庫県神戸市] 開場14:00開演14:00終演21:00出店者hana(体に優しい米粉スイーツ&パン)/Polaris(創作デリ)/自由カフェ(コーヒー)出演者ごろー村長(17:00〜)/jamzIp(15:00〜 19:00〜) 詳細

11/29 11/30 イベントフライヤー画像「おとなの文化祭」 in polarisデリカテッセン Polaris [兵庫県神戸市] 開演11:00終演19:00出演者睡蓮/スミフジオ/VO/GO/松岡葉子/jamzIp出店者polaris(フードドリンク)/kobe3curry(薬膳キーマカレー)/お針子かふぇ(スイーツ&フード)/qoo(気分が上がる痛くないターバン販売&qooの旅する星詠み)/自由カフェ(珈琲) 詳細

New Releases