왜 안되는 걸까~를 찾아보니, 퍼가기 허용이 안됐을 경우 외부에서 재생이 안된다는 것을 알게 되었다. 그럼 다른 음악 사이트를 활용해보자!
<audio> 태그로 완전한 커스텀 UI 구현 가능나는 이 중에서 스포티파이를 선택했다! 이유는 공식 음원을 트는 것이 내 목적이었기 때문이다.
스포티파이 개발자 문서를 보면 음원 임베드할 수 있도록 API를 공개해놨다. 나는 이걸 활용할 것이다.
문서를 보면 oEmbed API와 iFrame API로 두 개가 나눠져있는데, 두 개의 차이를 알아보자.
oEmbed는 Spotify URL에 대한 기본 정보와 embed 코드를 가져오는 API로, 주로 링크 미리보기(link unfurling) 기능에 사용돼요. spotify
동작 방식은 Spotify URL을 서버에 넘기면 JSON으로 아래 정보를 돌려줘요:
<iframe> HTML 코드즉, "이 Spotify 링크 어떤 내용이야?" 를 물어보는 API예요. 유저가 링크를 붙여넣었을 때 자동으로 미리보기 카드를 만들어주는 SNS나 메신저 같은 서비스에 적합해요.
iFrame API는 웹앱에서 동적으로 Spotify 팟캐스트 에피소드를 불러오고, 재생하고, 일시정지할 수 있게 해주는 API예요. spotify JS로 플레이어를 직접 조작할 수 있어서 이런 메서드들을 제공해요:
그리고 이벤트도 받을 수 있어요:
그럼 우리 LP 플레이의 기능을 살펴보면 썸네일 이미지도 필요하고, 이름도 필요하고 음원도 실행해야하니, 둘다 활용해야된다.
LP 디자인이 좀 구린거 같아서, 카세트 테이프로 변경했다.

음 스포티파이 임베드 해오면 실행버튼이랑 연결이 될 줄 알았는데, 그게 아니라 자기네 디자인을 꼭 넣어야하는 거였다. 유튜브도 마찮가지. 그래서 임베드가 아니라 음원 API를 가져와야하는 걸 알고 Soundcloud로 변경하려고 한다.

음! 이제 버튼과 잘 연동되었다!
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cassette Player</title>
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Share+Tech+Mono&display=swap" rel="stylesheet">
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--blue:#1a3a6e;--silver:#c8cdd6;--silver-d:#9aa0ac;--silver-l:#e8ecf2;
--dark:#1a1c20;--tape:#4a2810;
}
html,body{
min-height:100vh;background:#1e1e22;
display:flex;align-items:center;justify-content:center;
font-family:'Share Tech Mono',monospace;padding:2rem 1rem;
}
/* ── Walkman body ── */
.wk{
width:340px;background:var(--blue);border-radius:12px 12px 22px 22px;
position:relative;user-select:none;
box-shadow:4px 4px 0 var(--silver-d),8px 8px 0 rgba(0,0,0,.35),
inset 1px 1px 0 rgba(255,255,255,.15),inset -1px -1px 0 rgba(0,0,0,.3);
}
.wk::after{
content:'';position:absolute;right:-10px;top:8px;bottom:8px;width:10px;
background:linear-gradient(to right,var(--silver-d),var(--silver));
border-radius:0 6px 6px 0;box-shadow:2px 0 4px rgba(0,0,0,.3);
}
.screw{position:absolute;width:9px;height:9px;border-radius:50%;z-index:10;
background:radial-gradient(circle at 35% 35%,#aab0bc,#6a7080);border:1px solid #555;}
.screw::after{content:'';position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%) rotate(45deg);width:5px;height:1px;background:rgba(0,0,0,.5);}
.s-tl{top:6px;left:6px}.s-tr{top:6px;right:6px}.s-bl{bottom:6px;left:6px}.s-br{bottom:6px;right:6px}
/* top bar */
.topbar{
background:var(--silver);border-radius:10px 10px 0 0;padding:8px 14px;
display:flex;align-items:center;justify-content:space-between;
border-bottom:2px solid var(--silver-d);
}
.logo{font-family:'Orbitron',sans-serif;font-size:16px;font-weight:900;color:var(--dark);letter-spacing:3px;}
.top-r{display:flex;align-items:center;gap:10px;}
.jg{display:flex;flex-direction:column;align-items:center;gap:2px;}
.jlbl{font-size:7px;color:#555;letter-spacing:1px;}
.jacks{display:flex;gap:6px;}
.jack{width:8px;height:8px;border-radius:50%;background:#333;border:1.5px solid #555;box-shadow:inset 0 1px 2px rgba(0,0,0,.8);}
.led{width:8px;height:8px;border-radius:50%;background:#333;border:1px solid #555;transition:background .2s,box-shadow .2s;}
.led.on{background:#00ee44;box-shadow:0 0 7px #00ee44;}
/* cassette window */
.win-frame{
margin:10px 14px;background:#0d1420;border-radius:6px;padding:10px;
border:2px solid #060a10;position:relative;overflow:hidden;
box-shadow:inset 0 2px 8px rgba(0,0,0,.8),inset 0 -1px 2px rgba(255,255,255,.04);
}
.win-frame::before{
content:'';position:absolute;top:4px;left:6px;right:60%;height:32%;
background:linear-gradient(135deg,rgba(255,255,255,.07) 0%,transparent 100%);
border-radius:3px;pointer-events:none;z-index:20;
}
/* cassette */
.cass{width:100%;aspect-ratio:2.4;background:#f0e8d8;border-radius:5px;position:relative;overflow:hidden;border:1px solid #ddd;}
.clabel{position:absolute;inset:0;overflow:hidden;display:flex;align-items:center;justify-content:center;}
.clabel img{width:100%;height:100%;object-fit:cover;display:none;filter:brightness(.82) saturate(1.1);}
.clabel-def{
width:100%;height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;
background:linear-gradient(135deg,#0f0f18,#1a1a2e 50%,#0d1a3a);
}
.cld-brand{font-family:'Orbitron',sans-serif;font-size:7px;font-weight:700;color:rgba(255,255,255,.4);letter-spacing:3px;}
.cld-title{font-size:10px;color:rgba(255,255,255,.7);letter-spacing:1px;}
.side-a{position:absolute;top:5px;left:8px;font-family:'Orbitron',sans-serif;font-size:7px;font-weight:700;color:rgba(255,255,255,.55);letter-spacing:1px;z-index:6;}
.stereo-b{position:absolute;bottom:5px;right:8px;font-size:7px;color:rgba(255,255,255,.4);letter-spacing:2px;z-index:6;}
/* tape window */
.tape-win{
position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
width:54%;height:72%;background:rgba(4,7,14,.94);border-radius:3px;
display:flex;align-items:center;justify-content:space-around;padding:0 8px;z-index:5;
}
.reel{width:38px;height:38px;position:relative;display:flex;align-items:center;justify-content:center;}
.rhub{
width:100%;height:100%;border-radius:50%;position:relative;
display:flex;align-items:center;justify-content:center;
background:radial-gradient(circle at 35% 35%,#4a4a52,#1a1a1e);border:1.5px solid #555;
}
.wound{position:absolute;inset:0;border-radius:50%;border-style:solid;border-color:rgba(70,33,8,.92);transition:border-width .08s linear;}
.spoke{position:absolute;width:2px;height:13px;background:#666;top:50%;left:50%;transform-origin:50% 100%;border-radius:1px;}
.rc{width:10px;height:10px;border-radius:50%;background:radial-gradient(circle at 35% 35%,#888,#333);border:1px solid #777;z-index:2;}
.tape-strip{position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:28%;height:7px;background:var(--tape);z-index:3;}
.c-holes{position:absolute;bottom:4px;left:50%;transform:translateX(-50%);display:flex;gap:16px;z-index:6;}
.c-hole{width:5px;height:5px;border-radius:50%;background:rgba(0,0,0,.6);border:1px solid rgba(0,0,0,.4);}
/* labels */
.body-lbl{font-family:'Orbitron',sans-serif;font-size:10px;font-weight:700;color:rgba(255,255,255,.3);letter-spacing:4px;text-align:center;margin:4px 0 0;}
.tinfo{padding:8px 14px 2px;}
.tname{font-family:'Orbitron',sans-serif;font-size:11px;font-weight:700;color:rgba(255,255,255,.9);letter-spacing:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
.tsub{font-size:9px;color:rgba(255,255,255,.4);letter-spacing:1px;margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;}
/* progress */
.prog-wrap{padding:6px 14px 0;}
.prog-bg{width:100%;height:3px;background:rgba(255,255,255,.12);border-radius:2px;cursor:pointer;position:relative;}
.prog-fill{height:100%;background:rgba(255,255,255,.6);border-radius:2px;width:0%;}
.time-row{display:flex;justify-content:space-between;margin-top:3px;}
.ttxt{font-size:8px;color:rgba(255,255,255,.3);}
/* volume */
.vol-row{padding:6px 14px 0;display:flex;align-items:center;gap:8px;}
.vlbl{font-size:8px;color:rgba(255,255,255,.35);letter-spacing:2px;width:28px;}
.vslider{flex:1;height:3px;-webkit-appearance:none;appearance:none;background:rgba(255,255,255,.14);border-radius:2px;outline:none;cursor:pointer;}
.vslider::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:2px;background:var(--silver);border:1px solid var(--silver-d);cursor:pointer;box-shadow:1px 1px 3px rgba(0,0,0,.5);}
/* transport */
.transport{padding:8px 14px 14px;display:flex;gap:5px;align-items:flex-end;justify-content:center;}
.btn{display:flex;flex-direction:column;align-items:center;gap:4px;cursor:pointer;}
.bbody{
border-radius:4px;display:flex;align-items:center;justify-content:center;
border:none;cursor:pointer;position:relative;overflow:hidden;
transition:transform .08s,box-shadow .08s;
}
.bbody::after{content:'';position:absolute;top:0;left:0;right:0;height:40%;background:rgba(255,255,255,.12);border-radius:4px 4px 0 0;pointer-events:none;}
.bbody:active,.bbody.pressed{transform:translateY(2px)!important;box-shadow:none!important;}
.btn-gray .bbody{width:50px;height:27px;background:linear-gradient(to bottom,#6a7080,#4a5060);box-shadow:0 3px 0 #2a2f38,0 4px 6px rgba(0,0,0,.4);color:rgba(255,255,255,.85);font-size:14px;}
.btn-play .bbody{width:50px;height:27px;background:linear-gradient(to bottom,#6a7080,#4a5060);box-shadow:0 3px 0 #2a2f38,0 4px 6px rgba(0,0,0,.4);color:rgba(255,255,255,.9);font-size:14px;}
.btn-stop .bbody{width:50px;height:27px;background:linear-gradient(to bottom,#e03020,#a02010);box-shadow:0 3px 0 #601008,0 4px 6px rgba(0,0,0,.4);color:#fff;font-size:14px;}
.blbl{font-size:7px;color:rgba(255,255,255,.35);letter-spacing:1px;text-transform:uppercase;}
/* hidden SC iframe */
#sc-if{display:none;width:0;height:0;position:absolute;}
/* url input */
.input-sec{background:rgba(0,0,0,.25);border-top:1px solid rgba(0,0,0,.4);border-radius:0 0 20px 20px;padding:10px 14px 14px;display:flex;flex-direction:column;gap:6px;}
.irow{display:flex;gap:6px;}
.uin{flex:1;height:32px;padding:0 10px;font-family:'Share Tech Mono',monospace;font-size:11px;border-radius:4px;border:1px solid rgba(255,255,255,.13);background:rgba(0,0,0,.4);color:rgba(255,255,255,.85);outline:none;transition:border-color .2s;}
.uin:focus{border-color:rgba(255,255,255,.38);}
.uin::placeholder{color:rgba(255,255,255,.22);font-size:10px;}
.lbtn{height:32px;padding:0 12px;border-radius:4px;border:none;background:linear-gradient(to bottom,#e8720a,#b05008);color:#fff;font-family:'Share Tech Mono',monospace;font-size:11px;font-weight:700;cursor:pointer;box-shadow:0 2px 0 #702800;white-space:nowrap;transition:transform .08s;}
.lbtn:active{transform:translateY(1px);box-shadow:none;}
.hint{font-size:9px;color:rgba(255,255,255,.2);text-align:center;letter-spacing:.5px;line-height:1.6;}
</style>
</head>
<body>
<iframe id="sc-if" allow="autoplay; encrypted-media; fullscreen; clipboard-write"></iframe>
<script src="https://w.soundcloud.com/player/api.js"></script>
<div class="wk">
<div class="screw s-tl"></div><div class="screw s-tr"></div>
<div class="screw s-bl"></div><div class="screw s-br"></div>
<div class="topbar">
<span class="logo">MUSIC</span>
<div class="top-r">
<div class="jg">
<span class="jlbl">HEADPHONES</span>
<div class="jacks"><div class="jack"></div><div class="jack"></div></div>
</div>
<div class="led" id="led"></div>
</div>
</div>
<div class="win-frame">
<div class="cass">
<div class="clabel">
<div class="clabel-def" id="cLblDef">
<span class="cld-brand">SOUNDCLOUD</span>
<span class="cld-title">INSERT TAPE</span>
</div>
<img id="art" src="" alt="">
</div>
<div class="side-a">SIDE A</div>
<div class="stereo-b">STEREO</div>
<div class="tape-win">
<div class="reel">
<div class="rhub" id="reelL">
<div class="wound" id="wL" style="border-width:9px"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(0deg)"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(120deg)"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(240deg)"></div>
<div class="rc"></div>
</div>
</div>
<div class="tape-strip"></div>
<div class="reel">
<div class="rhub" id="reelR">
<div class="wound" id="wR" style="border-width:0.5px"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(60deg)"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(180deg)"></div>
<div class="spoke" style="transform:translate(-50%,-100%) rotate(300deg)"></div>
<div class="rc"></div>
</div>
</div>
</div>
<div class="c-holes">
<div class="c-hole"></div><div class="c-hole"></div><div class="c-hole"></div>
<div class="c-hole"></div><div class="c-hole"></div>
</div>
</div>
</div>
<div class="body-lbl">STEREO CASSETTE PLAYER</div>
<div class="tinfo">
<div class="tname" id="tname">— — —</div>
<div class="tsub" id="tsub">SoundCloud URL을 입력하세요</div>
</div>
<div class="prog-wrap">
<div class="prog-bg" id="progBg"><div class="prog-fill" id="progFill"></div></div>
<div class="time-row">
<span class="ttxt" id="tCur">0:00</span>
<span class="ttxt" id="tDur">0:00</span>
</div>
</div>
<div class="vol-row">
<span class="vlbl">VOL</span>
<input type="range" class="vslider" id="vol" min="0" max="100" value="80" step="1">
</div>
<div class="transport">
<div class="btn btn-gray">
<button class="bbody" id="bRew" aria-label="되감기">⏮</button>
<span class="blbl">REW</span>
</div>
<div class="btn btn-play">
<button class="bbody" id="bPlay" aria-label="재생">▶</button>
<span class="blbl" id="pLbl">PLAY</span>
</div>
<div class="btn btn-stop">
<button class="bbody" id="bStop" aria-label="정지">■</button>
<span class="blbl">STOP</span>
</div>
<div class="btn btn-gray">
<button class="bbody" id="bFF" aria-label="빨리감기">⏭</button>
<span class="blbl">FF</span>
</div>
</div>
<div class="input-sec">
<div class="irow">
<input class="uin" id="urlIn" type="text" placeholder="https://soundcloud.com/artist/track">
<button class="lbtn" id="loadBtn">LOAD</button>
</div>
<p class="hint">TRACK · PLAYLIST · ALBUM | SoundCloud URL을 붙여넣으세요</p>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const reelL=$('reelL'), reelR=$('reelR'), wL=$('wL'), wR=$('wR');
const led=$('led'), bPlay=$('bPlay'), pLbl=$('pLbl');
const progFill=$('progFill'), progBg=$('progBg');
const tCur=$('tCur'), tDur=$('tDur');
const tname=$('tname'), tsub=$('tsub');
const art=$('art'), cLblDef=$('cLblDef');
const vol=$('vol');
const MAX_B=9, MIN_B=0.5, TOTAL=100;
let tapePos=0, angL=0, angR=0, rafId=null, lastT=null;
let mode='stop', dur=0, pos=0;
let widget=null, ready=false;
function fmt(ms){ const s=Math.floor(ms/1000); return Math.floor(s/60)+':'+String(s%60).padStart(2,'0'); }
function lerp(a,b,t){ return a+(b-a)*t; }
function updateWound(){
const r=tapePos/TOTAL;
wL.style.borderWidth=lerp(MAX_B,MIN_B,r).toFixed(1)+'px';
wR.style.borderWidth=lerp(MIN_B,MAX_B,r).toFixed(1)+'px';
}
function animate(ts){
if(!lastT) lastT=ts;
const dt=Math.min((ts-lastT)/1000,0.05); lastT=ts;
const spd=mode==='play'?1:8;
if(mode==='play'){
angL-=spd*dt*180; angR+=spd*dt*180;
if(dur>0) tapePos=Math.min(TOTAL,Math.max(0,pos/dur*TOTAL));
} else if(mode==='ff'){
tapePos=Math.min(TOTAL,tapePos+spd*dt*8);
angL-=spd*dt*900; angR+=spd*dt*900;
if(tapePos>=TOTAL){setMode('stop');return;}
} else if(mode==='rew'){
tapePos=Math.max(0,tapePos-spd*dt*8);
angL+=spd*dt*900; angR-=spd*dt*900;
if(tapePos<=0){setMode('stop');return;}
}
reelL.style.transform=`rotate(${angL}deg)`;
reelR.style.transform=`rotate(${angR}deg)`;
updateWound();
rafId=requestAnimationFrame(animate);
}
function setMode(m){
mode=m;
if(rafId){cancelAnimationFrame(rafId);rafId=null;}
lastT=null;
const playing=m==='play';
led.classList.toggle('on',playing);
bPlay.textContent=playing?'⏸':'▶';
pLbl.textContent =playing?'PAUSE':'PLAY';
if(m!=='stop'){lastT=null;rafId=requestAnimationFrame(animate);}
}
/* ── SC Widget ── */
function initWidget(src){
$('sc-if').src=src;
widget=SC.Widget($('sc-if'));
ready=false;
widget.bind(SC.Widget.Events.READY,()=>{
ready=true;
widget.setVolume(parseInt(vol.value));
/* READY 시점에 곡 정보 즉시 로드 */
function loadMeta(){
widget.getCurrentSound(s=>{
if(!s)return;
tname.textContent=(s.title||'—').toUpperCase();
tsub.textContent =(s.user?s.user.username:'').toUpperCase();
const img=s.artwork_url||(s.user&&s.user.avatar_url)||'';
if(img){
art.src=img.replace('-large','-t500x500');
art.style.display='block'; cLblDef.style.display='none';
}
});
widget.getDuration(d=>{ if(d>0){ dur=d; tDur.textContent=fmt(d); } });
}
loadMeta();
widget.bind(SC.Widget.Events.PLAY,()=>{
setMode('play');
loadMeta(); /* 플레이리스트 트랙 전환 시 갱신 */
});
widget.bind(SC.Widget.Events.PAUSE,()=>{ if(mode==='play') setMode('stop'); });
widget.bind(SC.Widget.Events.FINISH,()=>{ setMode('stop'); tapePos=TOTAL; updateWound(); });
widget.bind(SC.Widget.Events.PLAY_PROGRESS,data=>{
pos=data.currentPosition;
if(data.relativePosition>0) dur=pos/data.relativePosition;
progFill.style.width=(data.relativePosition*100)+'%';
tCur.textContent=fmt(pos);
});
});
}
/* ── Transport ── */
bPlay.addEventListener('click',()=>{
if(!ready)return;
if(mode==='play'){ widget.pause(); setMode('stop'); }
else { widget.play(); setMode('play'); }
});
$('bStop').addEventListener('click',()=>{
if(!ready)return;
widget.pause(); setMode('stop');
});
/* FF / REW — hold */
const STEP=3000;
let ffIv=null, rewIv=null;
function doFF(){ if(!ready)return; widget.getPosition(p=>{ widget.getDuration(d=>widget.seekTo(Math.min(p+STEP,d-200))); }); }
function doRew(){ if(!ready)return; widget.getPosition(p=>widget.seekTo(Math.max(p-STEP,0))); }
function startFF(){ if(mode==='ff')return; setMode('ff'); $('bFF').classList.add('pressed'); doFF(); ffIv=setInterval(doFF,200); }
function endFF(){ clearInterval(ffIv); $('bFF').classList.remove('pressed'); if(mode==='ff') setMode(ready?'play':'stop'); }
function startRew(){ if(mode==='rew')return; setMode('rew'); $('bRew').classList.add('pressed'); doRew(); rewIv=setInterval(doRew,200); }
function endRew(){ clearInterval(rewIv); $('bRew').classList.remove('pressed'); if(mode==='rew') setMode(ready?'play':'stop'); }
$('bFF').addEventListener('mousedown',startFF);
$('bFF').addEventListener('touchstart',e=>{e.preventDefault();startFF();},{passive:false});
$('bFF').addEventListener('mouseup',endFF);
$('bFF').addEventListener('mouseleave',endFF);
$('bFF').addEventListener('touchend',endFF);
$('bRew').addEventListener('mousedown',startRew);
$('bRew').addEventListener('touchstart',e=>{e.preventDefault();startRew();},{passive:false});
$('bRew').addEventListener('mouseup',endRew);
$('bRew').addEventListener('mouseleave',endRew);
$('bRew').addEventListener('touchend',endRew);
/* volume */
vol.addEventListener('input',()=>{ if(ready) widget.setVolume(parseInt(vol.value)); });
/* progress click */
progBg.addEventListener('click',e=>{
if(!ready||dur===0)return;
const r=e.offsetX/progBg.offsetWidth;
widget.getDuration(d=>widget.seekTo(Math.floor(r*d)));
});
/* load */
function load(){
const url=$('urlIn').value.trim();
if(!url||!url.includes('soundcloud.com')){tname.textContent='SOUNDCLOUD URL을 입력하세요';return;}
setMode('stop');
tapePos=0; angL=0; angR=0;
reelL.style.transform=''; reelR.style.transform='';
updateWound();
ready=false; widget=null; dur=0; pos=0;
progFill.style.width='0%'; tCur.textContent='0:00'; tDur.textContent='0:00';
art.style.display='none'; cLblDef.style.display='flex';
tname.textContent='LOADING...'; tsub.textContent='';
const src=`https://w.soundcloud.com/player/?url=${encodeURIComponent(url)}&auto_play=false&show_artwork=true&enable_javascript_api=true`;
initWidget(src);
}
$('loadBtn').addEventListener('click',load);
$('urlIn').addEventListener('keydown',e=>{ if(e.key==='Enter') load(); });
updateWound();
</script>
</body>
</html>