จัด Playlist จาก YouTube, Spotify หรือไฟล์ในเครื่อง กดเล่นแล้วเปิดให้ มีคิว สุ่ม วนซ้ำ และส่งออก M3U

จัด playlist หลายชุด เพิ่มเพลงเป็นลิงก์ YouTube/Spotify (ดึงชื่อเพลงให้เองผ่าน oEmbed) หรือไฟล์ในเครื่อง กดเล่นแล้วเปิดด้วยแอปหรือเบราว์เซอร์ของเครื่อง มีถัดไป ย้อนกลับ สุ่ม วนซ้ำ แผง "กำลังเล่น" พร้อมปุ่ม ⏮ ⏭ และส่งออกเป็น .m3u
| คำสั่ง | ทำอะไร | ||
|---|---|---|---|
/playlist new <ชื่อ> | สร้าง playlist | ||
/playlist add <ชื่อ> <ลิงก์หรือไฟล์> [ชื่อเพลง] | เพิ่มเพลง | ||
/playlist show [ชื่อ] / lists | ดูเพลง / ดูทุก playlist | ||
/playlist play <ชื่อ> [ลำดับ] | เริ่มเล่น + เปิดแผง | ||
/playlist next / prev / now | ถัดไป / ย้อนกลับ / ตอนนี้ | ||
| `/playlist shuffle on\ | off / repeat on\ | off` | สุ่ม / วนซ้ำ |
/playlist remove <ชื่อ> <ลำดับ> | ลบเพลง | ||
/playlist delete <ชื่อ> confirm | ลบ playlist | ||
/playlist search <คำค้น> | เปิดค้นบน YouTube | ||
/playlist export <ชื่อ> | ส่งออก .m3u |
/plugin install media-playlist-th --marketplace tksoft11/claude-code-mods-th
ต้องตั้ง CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 ก่อนเปิด claude (ดู README หลัก)
เปิด hooks/register.tsx แล้วอ่านตามบทที่เขียนไว้ในคอมเมนต์:
Mod ไม่ได้เล่นเสียงเองและไม่รู้ว่าเพลงจบเมื่อไหร่ จึงกด "ถัดไป" เอง การคุม Spotify จากในนี้ต้องใช้ Spotify Web API + OAuth (และบัญชี Premium) ซึ่งเกินขอบเขตของ Mod นี้
hooks/register.tsx 272 lines1/**
2 * ============================================================================
3 * MOD 28: Media Playlist TH - จัด Playlist จาก YouTube, Spotify หรือไฟล์ในเครื่อง (ฉบับสอนทำ)
4 * ============================================================================
5 * แรงบันดาลใจจาก: Winamp และคิวเพลงของ Spotify
6 *
7 * หน้าที่ของ Mod นี้:
8 * 1. สร้าง playlist หลายชุด เพิ่มเพลงเป็นลิงก์ YouTube/Spotify หรือไฟล์ในเครื่อง (.mp3 .mp4 ...)
9 * 2. ใส่ลิงก์แล้วดึงชื่อเพลงให้เองผ่าน oEmbed ของ YouTube/Spotify
10 * 3. กดเล่นแล้วเปิดด้วยแอป/เบราว์เซอร์ของเครื่อง, มีถัดไป ย้อนกลับ สุ่ม วนซ้ำ
11 * 4. แผง "กำลังเล่น" พร้อมปุ่ม ⏮ ⏭ และส่งออกเป็นไฟล์ .m3u
12 *
13 * ข้อจำกัด: Mod ไม่ได้เล่นเสียงเอง และไม่รู้ว่าเพลงจบเมื่อไหร่ จึงกด "ถัดไป" เอง
14 * (คุม Spotify จากในนี้ต้องใช้ Spotify Web API + OAuth ซึ่งเกินขอบเขตของ Mod นี้)
15 */
16
17import type { EngineInterface, Register } from 'claude-code'
18
19interface Track {
20 title: string
21 target: string // URL หรือ path ของไฟล์
22}
23interface State {
24 playlists: Record<string, Track[]>
25 current?: string // ชื่อ playlist ที่เล่นอยู่
26 index: number
27 shuffle: boolean
28 repeat: boolean
29 order: number[] // ลำดับเล่น (สุ่มแล้วหรือเรียงปกติ)
30}
31
32const PANE = 'media-playlist-th'
33let state: State = { playlists: {}, index: 0, shuffle: false, repeat: true, order: [] }
34let isWindows = false
35let isMac = false
36
37async function save($: EngineInterface): Promise<void> {
38 await $.store.set('playlist', state)
39}
40
41// ==========================================================================
42// บทที่ 1: ดึงชื่อเพลงจากลิงก์ด้วย oEmbed (มาตรฐานที่เว็บใหญ่ๆ ให้ใช้ฟรี)
43// ==========================================================================
44async function titleOf($: EngineInterface, target: string): Promise<string> {
45 const endpoint = /youtu\.?be/.test(target) ? 'https://www.youtube.com/oembed?format=json&url=' : /open\.spotify\.com/.test(target) ? 'https://open.spotify.com/oembed?url=' : undefined
46 if (endpoint) {
47 const res = await $.http.fetch(endpoint + encodeURIComponent(target)).catch(() => undefined)
48 if (res?.ok) {
49 const body = JSON.parse(res.text) as { title?: string; author_name?: string }
50 if (body.title) return body.author_name && !body.title.includes(body.author_name) ? `${body.title} - ${body.author_name}` : body.title
51 }
52 }
53 return target.split(/[\\/]/).pop()?.replace(/\.[^.]+$/, '') || target
54}
55
56// ==========================================================================
57// บทที่ 2: เปิดด้วยแอปของเครื่อง (ส่ง target ผ่าน env กันคำสั่งแทรก)
58// ==========================================================================
59async function openTarget($: EngineInterface, target: string): Promise<void> {
60 const argv = isWindows ? ['powershell', '-NoProfile', '-NonInteractive', '-Command', "$ErrorActionPreference='Stop'; Start-Process -FilePath $env:MP_TARGET"] : isMac ? ['open', target] : ['xdg-open', target]
61 const r = await $.process.run(argv, { env: { MP_TARGET: target }, timeoutMs: 20_000 })
62 if (r.exitCode !== 0) throw new Error(r.stderr.trim() || `exit ${r.exitCode}`)
63}
64
65// ==========================================================================
66// บทที่ 3: คิวเพลง - สุ่มแบบ Fisher-Yates และเลื่อนไปมา
67// ==========================================================================
68function makeOrder(length: number, shuffle: boolean): number[] {
69 const order = Array.from({ length }, (_, i) => i)
70 if (shuffle) {
71 for (let i = order.length - 1; i > 0; i--) {
72 const j = Math.floor(Math.random() * (i + 1))
73 ;[order[i], order[j]] = [order[j] ?? 0, order[i] ?? 0]
74 }
75 }
76 return order
77}
78
79function currentTracks(): Track[] {
80 return state.current ? (state.playlists[state.current] ?? []) : []
81}
82
83function currentTrack(): Track | undefined {
84 return currentTracks()[state.order[state.index] ?? -1]
85}
86
87async function playAt($: EngineInterface, index: number): Promise<string> {
88 const tracks = currentTracks()
89 if (tracks.length === 0) return 'playlist ว่าง'
90 if (index >= tracks.length || index < 0) {
91 if (!state.repeat) return '⏹️ จบ playlist แล้ว (/playlist repeat on เพื่อวนซ้ำ)'
92 index = (index + tracks.length) % tracks.length
93 }
94 state.index = index
95 const track = currentTrack()
96 if (!track) return 'ไม่พบเพลง'
97 await openTarget($, track.target)
98 await save($)
99 $.ui.invalidate('ui.render')
100 return `▶️ ${index + 1}/${tracks.length} ${track.title}`
101}
102
103const HELP = `🎵 Media Playlist TH (Mod 28)
104========================================
105/playlist new <ชื่อ> สร้าง playlist
106/playlist add <ชื่อ> <ลิงก์หรือไฟล์> [ชื่อเพลง] ลิงก์ YouTube/Spotify ดึงชื่อให้เอง
107/playlist show [ชื่อ] | lists
108/playlist play <ชื่อ> [ลำดับ] เริ่มเล่น
109/playlist next | prev | now
110/playlist shuffle on|off | repeat on|off
111/playlist remove <ชื่อ> <ลำดับ> | delete <ชื่อ> confirm
112/playlist search <คำค้น> เปิดค้นบน YouTube
113/playlist export <ชื่อ> ส่งออก .m3u`
114
115export const register: Register = on => {
116 on('session.start', async ($, e, next) => {
117 isWindows = /^[A-Za-z]:/.test(e.cwd)
118 isMac = !isWindows && e.cwd.startsWith('/Users/')
119 const saved = (await $.store.get('playlist')) as Partial<State> | undefined
120 state = { playlists: {}, index: 0, shuffle: false, repeat: true, order: [], ...saved }
121 await $.command.register({ name: 'playlist', description: 'Playlist จาก YouTube Spotify หรือไฟล์ในเครื่อง (/playlist help)', argumentHint: 'new|add|play|next|prev|shuffle' })
122 return next(e)
123 })
124
125 // ==========================================================================
126 // บทที่ 4: แผงกำลังเล่น + ปุ่มควบคุม
127 // ==========================================================================
128 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
129 const { Box, Text, Button } = $.ui.resolve(e)
130 const tracks = currentTracks()
131 const now = currentTrack()
132 if (!state.current || !now) return <Text dimColor>เริ่มเล่นด้วย /playlist play <ชื่อ></Text>
133 const upNext = state.order.slice(state.index + 1, state.index + 6).map(i => tracks[i]).filter((t): t is Track => Boolean(t))
134 const move = (delta: number) => () => void playAt($, state.index + delta).then(msg => $.ui.toast(msg)).catch(err => $.ui.toast(`❌ ${String(err)}`))
135 return (
136 <Box flexDirection="column">
137 <Text dimColor>
138 🎵 {state.current} · {state.index + 1}/{tracks.length} {state.shuffle ? '🔀' : ''} {state.repeat ? '🔁' : ''}
139 </Text>
140 <Text bold wrap="truncate-end">
141 ▶️ {now.title}
142 </Text>
143 <Box gap={1}>
144 <Button key="prev" label="⏮" onPress={move(-1)} />
145 <Button key="next" label="⏭" onPress={move(1)} />
146 </Box>
147 {upNext.length > 0 && <Text dimColor>ถัดไป</Text>}
148 {upNext.map(t => (
149 <Text dimColor wrap="truncate-end">
150 · {t.title}
151 </Text>
152 ))}
153 </Box>
154 )
155 })
156
157 // ==========================================================================
158 // บทที่ 5: คำสั่ง /playlist
159 // ==========================================================================
160 on('command.run', { command: 'playlist' }, async ($, e) => {
161 const [sub = 'help', name = '', ...rest] = e.args.trim().split(/\s+/).filter(Boolean)
162 const list = state.playlists[name]
163
164 try {
165 switch (sub) {
166 case 'new':
167 if (!name) return { text: 'วิธีใช้: /playlist new ทำงาน' }
168 if (list) return { text: `ℹ️ มี playlist "${name}" อยู่แล้ว` }
169 state.playlists[name] = []
170 await save($)
171 return { text: `🎵 สร้าง playlist "${name}" แล้ว` }
172 case 'add': {
173 const [target, ...titleWords] = rest
174 if (!list || !target) return { text: list ? 'วิธีใช้: /playlist add <ชื่อ> <ลิงก์หรือไฟล์> [ชื่อเพลง]' : `❌ ไม่มี playlist "${name}" (สร้างด้วย /playlist new ${name})` }
175 const title = titleWords.join(' ') || (await titleOf($, target))
176 list.push({ title, target })
177 if (state.current === name) state.order = makeOrder(list.length, state.shuffle)
178 await save($)
179 return { text: `➕ ${list.length}. ${title}` }
180 }
181 case 'lists':
182 return { text: Object.keys(state.playlists).length ? ['🎵 Playlist ทั้งหมด', ...Object.entries(state.playlists).map(([n, t]) => ` ${n} (${t.length} เพลง)`)].join('\n') : 'ยังไม่มี playlist สร้างด้วย /playlist new <ชื่อ>' }
183 case 'show': {
184 const showName = name || state.current || ''
185 const tracks = state.playlists[showName]
186 if (!tracks) return { text: `❌ ไม่มี playlist "${showName}"` }
187 return { text: [`🎵 ${showName} (${tracks.length} เพลง)`, ...tracks.map((t, i) => ` ${i + 1}. ${t.title}`)].join('\n') }
188 }
189 case 'play': {
190 if (!list || list.length === 0) return { text: list ? `playlist "${name}" ยังว่าง` : `❌ ไม่มี playlist "${name}"` }
191 state.current = name
192 state.order = makeOrder(list.length, state.shuffle)
193 const start = Number(rest[0]) - 1
194 await $.ui.open({ id: PANE, title: '🎵 Now Playing' })
195 // เลือกเพลงที่ระบุ: หาตำแหน่งของเพลงนั้นในลำดับเล่น
196 return { text: await playAt($, Number.isInteger(start) && start >= 0 ? Math.max(0, state.order.indexOf(start)) : 0) }
197 }
198 case 'next':
199 case 'prev':
200 if (!state.current) return { text: 'ยังไม่ได้เล่น /playlist play <ชื่อ>' }
201 return { text: await playAt($, state.index + (sub === 'next' ? 1 : -1)) }
202 case 'now': {
203 const t = currentTrack()
204 return { text: t ? `▶️ ${t.title}\n${t.target}` : 'ยังไม่ได้เล่น' }
205 }
206 case 'shuffle':
207 case 'repeat': {
208 const value = name === 'on'
209 if (sub === 'shuffle') {
210 state.shuffle = value
211 // สุ่มใหม่โดยให้เพลงที่เล่นอยู่เป็นเพลงแรก
212 const playing = state.order[state.index]
213 state.order = makeOrder(currentTracks().length, value)
214 if (playing !== undefined) state.order = [playing, ...state.order.filter(i => i !== playing)]
215 state.index = 0
216 } else {
217 state.repeat = value
218 }
219 await save($)
220 $.ui.invalidate('ui.render')
221 return { text: `${sub === 'shuffle' ? '🔀 สุ่ม' : '🔁 วนซ้ำ'} ${value ? 'เปิด' : 'ปิด'}` }
222 }
223 case 'remove': {
224 const n = Number(rest[0])
225 if (!list || !Number.isInteger(n) || n < 1 || n > list.length) return { text: 'วิธีใช้: /playlist remove <ชื่อ> <ลำดับ>' }
226 const [removed] = list.splice(n - 1, 1)
227 if (state.current === name) {
228 state.order = makeOrder(list.length, state.shuffle)
229 state.index = 0
230 }
231 await save($)
232 return { text: `🗑️ ลบ "${removed?.title}" แล้ว` }
233 }
234 case 'delete':
235 if (!list) return { text: `❌ ไม่มี playlist "${name}"` }
236 if (rest[0] !== 'confirm') return { text: `⚠️ จะลบ playlist "${name}" (${list.length} เพลง) ยืนยันด้วย /playlist delete ${name} confirm` }
237 delete state.playlists[name]
238 if (state.current === name) state.current = undefined
239 await save($)
240 $.ui.invalidate('ui.render')
241 return { text: `🗑️ ลบ playlist "${name}" แล้ว` }
242 case 'search': {
243 const q = [name, ...rest].join(' ')
244 if (!q) return { text: 'วิธีใช้: /playlist search เพลงใหม่ ล่าสุด' }
245 await openTarget($, `https://www.youtube.com/results?search_query=${encodeURIComponent(q)}`)
246 return { text: `🔎 เปิดค้น "${q}" บน YouTube แล้ว เจอเพลงที่ชอบ copy ลิงก์มา /playlist add ได้เลย` }
247 }
248 case 'export': {
249 if (!list) return { text: `❌ ไม่มี playlist "${name}"` }
250 const root = (await $.session.cwd()).replace(/[\\/]$/, '')
251 const path = `${root}${root.includes('\\') ? '\\' : '/'}${name.replace(/[\\/:*?"<>|]/g, '_')}.m3u`
252 await $.fs.write(path, ['#EXTM3U', ...list.flatMap(t => [`#EXTINF:-1,${t.title}`, t.target])].join('\n') + '\n')
253 return { text: `💾 ส่งออกแล้ว: ${path} (เปิดด้วย VLC หรือโปรแกรมเล่นเพลงได้)` }
254 }
255 default:
256 return { text: HELP }
257 }
258 } catch (err) {
259 return { text: `❌ ${sub} ไม่สำเร็จ: ${err instanceof Error ? err.message : String(err)}` }
260 }
261 })
262}
263
264/**
265 * ========================================================================
266 * การบ้าน / ต่อยอด
267 * ========================================================================
268 * 1. นำเข้า playlist จากไฟล์ .m3u หรือโฟลเดอร์เพลงทั้งโฟลเดอร์ ($.fs.list)
269 * 2. คุม Spotify จริงด้วย Spotify Web API (ต้องทำ OAuth และมี Premium)
270 * 3. ตั้งเวลาเปิด playlist ตอนเช้า (ต่อกับ Mod 24)
271 */
272