https://github.com/heonys/topzl-desktop
πΆCross-platform music streaming application with Electron
https://github.com/heonys/topzl-desktop
electron electron-vite music-player react typescript
Last synced: 4 months ago
JSON representation
πΆCross-platform music streaming application with Electron
- Host: GitHub
- URL: https://github.com/heonys/topzl-desktop
- Owner: Heonys
- Created: 2024-11-10T13:04:57.000Z (over 1 year ago)
- Default Branch: master
- Last Pushed: 2025-05-12T14:59:45.000Z (about 1 year ago)
- Last Synced: 2025-07-19T09:20:46.708Z (11 months ago)
- Topics: electron, electron-vite, music-player, react, typescript
- Language: TypeScript
- Homepage:
- Size: 24.2 MB
- Stars: 2
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
![Main Page][main-screenshot]
## π Introduction
**Topzl**μ κ΄κ³ μλ λ¬΄λ£ μμ
μ€νΈλ¦¬λ°μ μν λ°μ€ν¬ν μ΄ν리μΌμ΄μ
μ
λλ€. μ΅μ `Electron` λ²μ κ³Ό `electron-vite`λ₯Ό κΈ°λ°μΌλ‘ UIλ `React` νκ²½κ³Ό ν΅ν©νμ¬ κ°λ° λμμ΅λλ€. ν¬λ‘μ€ νλ«νΌ μ§μμ ν΅ν νΈνμ±κ³Ό μλ¦λ€μ΄ μΈν°νμ΄μ€ λ° λ€μν κΈ°λ₯μ μ 곡νλ κ²μ λͺ©νλ‘ ν©λλ€.
μννΈμ¨μ΄ λ€μ΄λ‘λλ μ μ₯μμ [Releases](https://github.com/Heonys/topzl-desktop/releases) νμ΄μ§μμ νμΈν μ μμ΅λλ€. νμ¬λ `Windows` νκ²½μμλ§ μμ μ μΌλ‘ λμνκΈ°μ `Windows` μ μ©μΌλ‘ μ 곡λ©λλ€.
> **notice**: νμ¬ λ¦΄λ¦¬μ¦ λ²μ μ `Windows` λ§μ μ 곡νμ§λ§ [ν΄λΌμ΄μΈνΈ ν¨ν€μ§](#ν΄λΌμ΄μΈνΈ-ν¨ν€μ§)μ ν΅ν΄μ λ€λ₯Έ νλ«νΌμμ μ§μ ν¨ν€μ§μ΄ κ°λ₯ν©λλ€. λ€λ§ μ΄ κ²½μ° λ©λ΄, νΈλ μ΄ λ± μΌλΆ κΈ°λ₯μμ μ°¨μ΄κ° μμ μ μμ΅λλ€.
## β οΈ**Important**
μ΄ νλ‘μ νΈλ [η«ε€΄η«/MusicFreePlugins](https://gitee.com/maotoumao/MusicFreePlugins) μ μ₯μμ `audiomack` νλ¬κ·ΈμΈμ μ¬μ©νμ¬ μμμ μ¬μ URLμ κ°μ Έμ΅λλ€. λ΄λΆμ μΌλ‘λ [audiomack api](https://audiomack.com/data-api/docs)λ₯Ό μ¬μ©νμ§λ§ μ λ£ μ»¨ν
μΈ λ νν°λ§λμ΄ μμ΅λλ€. μ΄ νλ¬κ·ΈμΈμ νμ΅ λ° μ°Έκ³ μ©λλ‘λ§ μ 곡λλ©°, μμ
μ μ©λλ‘ μ¬μ©νμ§ μμμΌ νκ³ , λ°λμ ν©λ²μ μΌλ‘ μ¬μ©ν΄μΌ νλ€κ³ μ μλμ΄ μμ΅λλ€.
`Topzl`μμ λμΌν λͺ©μ μΌλ‘ κ°λ°λ νλ‘μ νΈ μ
λλ€. νλ‘μ νΈ μ¬μ© κ³Όμ μμ μ μκΆμ΄ μλ λ°μ΄ν°κ° μμ±λ μ μμΌλ―λ‘ μ΄μ λν μ£Όμκ° νμν©λλ€. λν, νμ¬ κ°μΈμ© λ° νμ΅μ©μΌλ‘λ§ μ¬μ©μ κΆμ₯νλ©°, μ½λ μ¬μ΄λ μμ΄ λ°°ν¬λκ³ μκΈ°μ μ€μΉ μ μ΄μ 체μ μμ κ²½κ³ λ©μμ§κ° νμλ μ μμ΅λλ€.
## β¨ Features
- ν¬λ‘μ€ νλ«νΌ μ§μ (Windows, macOS, Linux)
- μμ
, μ¨λ², μν°μ€νΈ, νλ μ΄λ¦¬μ€νΈ κ²μ
- μ체 μ€νΈλ¦¬λ°
- λ‘컬 μμ
μ¬μ μ§μ
- μμ λ€μ΄λ‘λ μ§μ
- μ컀 μ€λ λλ₯Ό νμ©ν λ‘컬 ν΄λ λͺ¨λν°λ§ λ° λκΈ°ν
- κ°μ¬ μ§μ (μΉ ν¬λ‘€λ§ κΈ°λ°, μ νλ λΆμμ )
- λ‘κ·ΈμΈ μμ΄ μ¬μ© κ°λ₯ (μ€ν λ¦¬μ§ λ° AppDataμ μ¬μ©μ λ°μ΄ν° μ μ₯)
- λ€κ΅μ΄ μ§μ (νκ΅μ΄, μμ΄)
- μ¬μ©μ μ§μ λ¨μΆν€ μ§μ (In-App, Global)
- μΈλΆ μ€μ μ§μ (μΌλ°, μ¬μ, λ€μ΄λ‘λ, κ°μ¬, λ°±μ
λ° λ³΅μ)
- PIP λͺ¨λ μ§μ
## πΌοΈ Screenshot
μ€ν¬λ¦°μ·μ νμΈ νλ €λ©΄ νΌμ³μ£ΌμΈμ
![Main][main-screenshot]
![Search][search-screenshot]
![Search Album][seach_album-screenshot]
![Detail][detail-screenshot]
![Libray][library-screenshot]
![Palylist][playlist-screenshot]
![Local][local-screenshot]
![Download][download-screenshot]
![Pipmode][pipmode-screenshot]
![Setting1][settings1-screenshot]
## π Getting Started
- #### κ°λ° νκ²½ μ
μ
```sh
# μ μ₯μ ν΄λ‘
git clone https://github.com/Heonys/topzl-desktop.git
# μμ‘΄μ± μ€μΉ
yarn install
# κ°λ° μλ² μ€ν
yarn dev
```
- #### ν΄λΌμ΄μΈνΈ ν¨ν€μ§
νμ¬ λ¦΄λ¦¬μ¦λ λ²μ μ μμ μ μΈ `Windows`λ§ μ 곡λμ§λ§ `macOS`μ `Linux`λ₯Ό ν΄λΌμ΄μΈνΈμμ μ§μ ν¨ν€μ§ ν μ μλλ‘ μ€μ λμ΄ μμ΅λλ€. μ΄λ₯Ό ν΅ν΄ λ€λ₯Έ μ΄μ체μ μμλ μ§μ ν¨ν€μ§νμ¬ μ€νμ΄ κ°λ₯νκ³ `electron-builder.json` νμΌ μμ λΉλ μ΅μ
μ μμ ν μ μμ΅λλ€. λ¨, λ¦΄λ¦¬μ¦ λ²μ κ³Ό λμΌνκ² λμνλ©΄ νκ²½λ³μλ‘ `GENIUS_ACCESS_TOKEN`μ λ±λ‘ ν΄μΌ ν©λλ€.
> **Note**: μμΈν λΉλ μ€μ μ [electron-builder](https://www.electron.build/) λ¬Έμ μμ νμΈ κ°λ₯ν©λλ€.
```json
// electron-builder.json
"win": {
"target": ["nsis", "zip"],
},
"mac": {
"target": ["dmg"],
},
"linux": {
"target": ["AppImage"],
},
```
```sh
# .env
MAIN_VITE_GENIUS_ACCESS_TOKEN="..."
```
```sh
yarn dist:{flatform} # [win, mac, linux]
```
## π§© Technical Detail
π λͺ©λ‘
+ **[Electornμ κΈ°λ³Έ ꡬ쑰 λ° λμμ리](#1-electornμ-κΈ°λ³Έ-ꡬ쑰-λ°-λμμ리)**
+ **[λ€μ€ μλμ°κ° ν΅μ ](#2-λ€μ€-μλμ°κ°-ν΅μ )**
+ **[μ컀 μ€λ λ](#3-μ컀-μ€λ λ)**
+ **[νλ¬κ·ΈμΈ (Audiomack)](#4-νλ¬κ·ΈμΈ-audiomack)**
+ **[κ°μ¬ κ²μ](#5-κ°μ¬-κ²μ)**
+ **[κ°μ μ€ν¬λ‘€ (useVirtualScroll)](#6-κ°μ-μ€ν¬λ‘€-usevirtualscroll)**
+ **[μ¬μλͺ©λ‘ μ λ ¬ (Drag & Drop)](#7-μ¬μλͺ©λ‘-μ λ ¬-drag--drop)**
+ **[Scroll Navigator](#8-scroll-navigator)**
+ **[Focusμ Blur μ΄λ²€νΈ νλ¦ μ μ΄](#9-focusμ-blur-μ΄λ²€νΈ-νλ¦-μ μ΄)**
+ **[컨ν
μ€νΈ λ©λ΄](#10-컨ν
μ€νΈ-λ©λ΄)**
+ **[EventEmitter](#11-eventemitter)**
+ **[λ¨μΆν€ λ±λ‘ (In-App, Global)](#12-λ¨μΆν€-λ±λ‘-in-app-global)**
+ **[λ‘컬 λ°μ΄ν° κ΄λ¦¬](#13-λ‘컬-λ°μ΄ν°-κ΄λ¦¬)**
+ **[νλ©΄ μΊ‘μ²](#14-νλ©΄-μΊ‘μ²)**
### 1. Electornμ κΈ°λ³Έ ꡬ쑰 λ° λμμ리
`Electron`μ ν¬λ‘μ€ νλ«νΌ λ°μ€ν¬ν μ΄ν리μΌμ΄μ
μ λ§λ€ μ μκ² ν΄μ£Όλ νλ μμν¬ μ
λλ€. `Chromium` μμ§κ³Ό `Node JS`λ₯Ό ν΅ν©νμ¬ μΉκΈ°μ μ κ·Έλλ‘ μ¬μ©ν μ μμΌλ©°, λ€μν νλ μ μν¬μ ν΅ν©νμ¬ μ¬μ©ν μ μλ κ²μ΄ νΉμ§μ
λλ€. μΌλ νΈλ‘ μ κΈ°λ³Έμ μΌλ‘ λ©μΈ νλ‘μΈμ€μ λ λλ¬ νλ‘μΈμ€λ‘ λλκ² λλκ² λ©λλ€.
`Main Process`λ μλμ°λ₯Ό μμ±ν μ μμΌλ©° μΌλ νΈλ‘ μ± μ 체μ μλͺ
μ£ΌκΈ°λ₯Ό κ΄λ¦¬νκ³ μνΈμμ© ν©λλ€. `System API`μ μ κ·Όν μ μμ΄ λ°μ€ν¬ν μλ¦Ό, μμ€ν
νΈλ μ΄ λ±μ κΈ°λ₯ λν μνΈμμ©μ΄ κ°λ₯νλ©°, `Node.js` λ°νμμμ λμνκΈ°μ `npm` ν¨ν€μ§ μ¬μ© λλ `fs`, `os` λ±μ λ΄μ₯ λͺ¨λ λν μμ μ¬μ©ν μ μμ΅λλ€.
`Renderer Process`λ μ€μ μ¬μ©μ μΈν°νμ΄μ€λ₯Ό λ λλ§νλ νλ‘μΈμ€λ‘ λ©μΈ νλ‘μΈμ€μμ λ§λ€μ΄μ§ μλμ°κ° λ λλ¬ νλ‘μΈμ€μ μ€ννκ²½μ΄ λμ΄ `UI`λ₯Ό λ λλ§ν μ μλ `Chromium` κΈ°λ°μ λΈλΌμ°μ μ°½μ μ 곡νκ² λ©λλ€.
λ λλ¬ νλ‘μΈμ€λ κ²°κ΅ `Node` νκ²½ μμμ μ€νλκΈ° λλ¬Έμ μ§μ λ
Έλ λͺ¨λμ μ κ·Όνλ κ²μ΄ κ°λ₯ν©λλ€. νμ§λ§ μΌλ°μ μΈ μΌλ νΈλ‘ κ°λ°μμ 보μμ μν΄ λΈλΌμ°μ νκ²½κ³Ό λ
Έλ νκ²½μ μμ ν 격리 μμΌμ μ€ννκΈ° λλ¬Έμ νλ‘μΈμ€κ°μ `IPC` ν΅μ μ ν΅ν΄μ λ°μ΄ν°λ₯Ό μ£Όκ³ λ°μΌλ©°, μμ ν `IPC`ν΅μ μ μν΄μ `preload.js` νμΌμ μ¬μ©ν©λλ€.
```js
// preload.js
const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("electron", {
sendMessage: (msg) => ipcRenderer.send("message", msg),
});
```
```js
// renderer process
window.electron.sendMessage("Hello from Renderer!");
// contextBridgeμμ apiλ₯Ό λ
ΈμΆμμΌ°κΈ° λλ¬Έμ window κ°μ²΄μ μμ±μ΄ μΆκ°λ¨
```
`preload.js`λ λ λλ¬ νλ‘μΈμ€κ° μ΄κΈ°ν λκΈ°μ μ μ€νλλ μ€ν¬λ¦½νΈλ‘ λ©μΈ νλ‘μΈμ€μ μ μ¬ν κΆνμ κ°μ§λ νΉμν νμΌμ
λλ€. `contextBridge`λ₯Ό ν΅ν΄μ μμ νκ² λ λλ¬ νλ‘μΈμ€μ λ
ΈμΆνλ €λ `API`λ₯Ό μ μν μ μκ³ , μ΄λ₯Ό ν΅ν΄ λ©μΈ νλ‘μΈμ€μ λ λλ¬ νλ‘μΈμ€κ°μ μμ ν ν΅μ μ κ°λ₯νκ² νλ μ€κ°λ€λ¦¬ μν μ ν©λλ€.
>μ¦, μΌλ νΈλ‘ μ λ λλ¬ νλ‘μΈμ€λ₯Ό ν΅ν΄ μ¬μ©μμ μνΈμμ© νλ UIλ₯Ό μ 곡νλ©°, λ©μΈ νλ‘μΈμ€μμ IPC ν΅μ μ ν΅ν΄ λ°μ΄ν°λ₯Ό μμ²νκ³ νλ©΄μ μ
λ°μ΄νΈνλ λ°©μμΌλ‘ λμν©λλ€.
### 2. λ€μ€ μλμ°κ° ν΅μ
λ©μΈ νλ‘μΈμ€λ μ¬λ¬ κ°μ μλμ°λ₯Ό μμ±ν μ μμΌλ©° κ° λ λλ¬ νλ‘μΈμ€λ λ©μΈ νλ‘μΈμ€μμ ν΅μ μ ν©λλ€. νμ§λ§ `IPC`ν΅μ μΌλ‘λ λ©μΈκ³Ό λ λλ¬κ°μ μν΅λ§ κ°λ₯νκΈ°μ λ λλ¬ νλ‘μΈμ€κ° μνλ₯Ό 곡μ νκΈ° μν΄μλ λ©μΈ νλ‘μΈμ€μμ μνλ₯Ό μ€κ°ν΄μΌλ§ ν©λλ€.
νμ¬ `Topzl` μ΄ν리μΌμ΄μ
μμ λ©μΈ μλμ° μΈμ μμ νλ μ΄μ΄ ννμ `PIP`λͺ¨λλ₯Ό μ§μν©λλ€. `PIP`λͺ¨λλ λ©μΈ μλμ°μ μ¬μμν λλ νμ¬ μ¬μ μ€μΈ 곑μ μ 보λ₯Ό 곡μ νλ©°, μ¬μ, μ΄μ 곑, λ€μ 곑μ λ²νΌμ μ 곡ν©λλ€. μ¦, `PIP`λͺ¨λλ₯Ό μ 곡νλ μλμ°λ λ©μΈ μλμ°μ λμμ μ€νλλ©° μνλ₯Ό μΌμ λΆλΆ 곡μ νκ³ λ°λλ‘ λ©μΈ μλμ°μ μνλ₯Ό μμ ν μ μμ΄μΌ ν©λλ€. μ΄λ₯Ό μν΄μ `IPC`ν΅μ λμ `MessageChannelMain`λ₯Ό μ¬μ©νμ¬ ν¬νΈκ°μ ν΅μ μ ν΅ν΄μ λ©μμ§ μ λ¬κ³Ό μνλ₯Ό 곡μ ν μ μλλ‘ κ΅¬ν νμ΅λλ€.
```ts
// pipmode wiondowκ° μμ±λλ μμ μ ν¬νΈλ₯Ό μ°κ²°νμ¬ mainwindowμ μνλ₯Ό μ λ¬
const { port1, port2 } = new MessageChannelMain();
mainWindow.webContents.postMessage("port", null, [port1]);
pipWindow.webContents.postMessage("port", { track: currentItem, state }, [port2]);
```
μ΄λ κ² νλ©΄ κ° μλμ°λ€μ μμ μ΄ μ°κ²°λ ν¬νΈλ‘ λΆν° λ°λνΈ ν¬νΈμκ² λ©μμ§λ₯Ό λ³΄λΌ μ μκ² λ©λλ€. `mainWindow`λ `port1`κ³Ό μ°κ²°λμ΄ νΈλμ΄ λ°λκ±°λ, νμ¬ νλ μ΄μ΄μ μνκ° λ°λλ©΄ `port2`μ λ©μμ§λ₯Ό μ λ¬ν©λλ€. λ°λ©΄ `pipmodeWinodw`λ `port2`μ μ°κ²°λμ΄ `port1`μΌλ‘λΆν° λ°μ μνλ‘ λΆν° νμ¬ μλμ°μ λκΈ°νν©λλ€ λν `pipmodeWindow`μμλ νΈλ μ 보λ₯Ό λ°κΏ μ μμ§λ§, μ§μ `mainWindow`μ μνλ₯Ό λ°κΏ μ μκΈ° λλ¬Έμ `mainWindow`μμ 미리 λ©μμ§ νΈλ€λ¬λ₯Ό μ€μ νκ³ `port1`μκ² νΉμ μ΄λ²€νΈλ₯Ό μ€ννλλ‘ λ©μμ§ μ λ¬νλλ‘ νμ¬ `mainWindow`μ μνλ₯Ό λ°κΎΈκ² λ©λλ€.
### 3. μ컀 μ€λ λ
`Topzl` μμλ λ‘컬 νμΌ λͺ¨λν°λ§μ μν `FileWatcher` μ컀μ λ€μ΄λ‘λλ₯Ό μ§ννκ³ κ·Έ λ€μ΄λ‘λμ μνλ₯Ό μ€μκ°μΌλ‘ λ λλ¬μκ² μ λ¬ν΄ μ£Όλ `Download` μ컀 μ΄λ κ² 2κ°μ§ μ컀 μ€λ λλ₯Ό μ¬μ©ν©λλ€. `Comlink` λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©νμ¬ λ©μΈ μ€λ λμ μ컀 μ€λ λκ°μ μνΈμμ©μ λ©μλλ₯Ό νΈμΆνλ λ°©μμΌλ‘ λ κ°κ²°νκ² μμ±νμμ΅λλ€.
- #### 1) λ€μ΄λ‘λ λ° λ€μ΄λ‘λ μν λκΈ°ν
μμ λ€μ΄λ‘λ λν μ컀 μ€λ λμμ μ€νλ©λλ€. λ€μ΄λ‘λκ° μμλλ©΄ λ λλ¬ νλ‘μΈμ€λ‘ λΆν° μ λ¬λ°μ `URL`μ ν¨μΉν©λλ€. `fetch API`λ λ
Έλμμλ μ§μνμ§λ§ μ¬κΈ°μ μ£Όμν΄μΌν μ μ `fetch`λ‘ ν¨μΉλ κ²°κ³Όλ `ReadableStream` κ°μ²΄μΈλ° μ΄λ μΉνκ²½ μμ μ¬μ©λλ μ€νΈλ¦Όμ΄κΈ° λλ¬Έμ μ΄λ₯Ό `Node`μμ μ¬μ© κ°λ₯ν μ€νΈλ¦ΌμΌλ‘ λ³νν΄μΌ ν©λλ€. μ΄ ν `writeStream`μ λ§λ€μ΄μ νμ΄νλΌμΈμ μ°κ²°ν΄ μ£Όκ³ , μλ¬κ° λ°μνκ±°λ νμ΄νμ΄ μλ£λλ©΄ λ λλ¬λ¬ μκ² μλ € νλ©΄μ μ
λ°μ΄νΈ ν μ μλλ‘ ν©λλ€.
```ts
async downloadFile(id: string, mediaSource: string, filePath: string) {
const response = await fetch(mediaSource);
const webStream = response.body as ReadableStream;
const readableStream = Readable.fromWeb(webStream);
const writeStream = fs.createWriteStream(filePath);
pipeline(readableStream, writeStream, (err) => {
if (err) {
this.state = DownloadState.ERROR;
this._onChange({ id, state: this.state, message: err.message });
this.removeFile(filePath);
} else {
this.state = DownloadState.DONE;
this._onChange({ id, state: this.state, current: total, total });
}
});
}
```
- #### 2) λ‘컬 ν΄λ λͺ¨λν°λ§
λ‘컬 νμ΄μ§μμ ν΄λλ₯Ό λ±λ‘νκ³ κ·Έ ν΄λμμ μ€λμ€ νμΌμ λ©νλ°μ΄ν°λ₯Ό μ λΆ λ½μμ 리μ€νΈλ‘ 보μ¬μ€λλ€. μ΄ κ³Όμ μμ `fs`λͺ¨λμ `watch` λ©μλμ μ μ¬ν κΈ°λ₯μ μ 곡νλ `chokidar` λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©νμ¬ νμΌ μμ€ν
κ°μ§λ₯Ό μ 곡ν©λλ€. μ΄λ₯Ό ν΅ν΄ λ±λ‘ν ν΄λμ λ³νκ° μΌμ΄λλ©΄ κ·Έ λ³νλ₯Ό λ λλ¬ νλ‘μΈμ€μ μ λ¬ν΄μ νλ©΄μ μ
λ°μ΄νΈνκ³ ν΄λ μ체μ κ²½λ‘κ° λ°λλ©΄ λͺ¨λν°λ§μ λ€μ μμ ν©λλ€.
### 4. νλ¬κ·ΈμΈ (Audiomack)
μμ
μ μ¬μνλ €λ©΄ μ€μ μ€λμ€ νμΌμ μ 곡νλ `Media Source`κ° νμν©λλ€. μ΄λ₯Ό μν΄ [η«ε€΄η«/MusicFreePlugins](https://gitee.com/maotoumao/MusicFreePlugins) μ μ₯μμ `audiomack` νλ¬κ·ΈμΈμ μ¬μ©νμ΅λλ€. [Audiomack](https://audiomack.com/)μ 무λ£λ‘ μμ
μ μ€νΈλ¦¬λ°ν μλΉμ€μΈλ° μ΄ νλ¬κ·ΈμΈμ λ΄λΆμ μΌλ‘λ [Audiomack API](https://audiomack.com/data-api/docs)μ μ¬μ©νλ©° μΉ΄ν
κ³ λ¦¬λ³ κ²μ λ° κ³‘μ μ¬μ `URL`μ κ°μ Έμ¬ μ μλ λ©μλλ₯Ό μ 곡ν©λλ€.
```ts
// κ²μ κ²°κ³Όμ λν νμ
, κΈ°λ³Έμ μΌλ‘ νμ΄μ§λ€μ΄μ
μ§μ
type SearchResult = {
isEnd: boolean;
data: {
id: string;
album: string;
artist: string;
artwork: string;
duration: number;
title: string;
}
};
```
μ΄λ κ² κ²μλ μμμ `ID`λ₯Ό μ΄μ©ν΄ `Media Source` `URL`μ κ°μ Έμ¬ μ μμ΅λλ€. μ€μ λ‘λ λ§λ£ μκ°, μκ·Έλμ³, ν€ νμ΄κ° νλΌλ―Έν°λ‘ ν¬ν¨λμ΄ μκ³ μλμ κ°μ ννμ `URL`μ
λλ€.
```sh
https://music.audiomack.com/albums/r0m1/red-planet/5ad60e011e7e3.mp3?${Parameters}
```
### 5. κ°μ¬ κ²μ
κΈ°λ³Έμ μΌλ‘ [Genius API](https://docs.genius.com/)λ₯Ό μ¬μ©νμ¬ μμμ κ°μ¬λ₯Ό κ²μν©λλ€.
`Genius`λ λ―Έκ΅μμ μμ
κ°μ¬λ₯Ό μ 곡νλ μλΉμ€λ‘ λΉμμ΄κΆμ μμ
μ κ²½μ° μμ΄ λ°μλλ‘ νκΈ°νλ λ‘λ§μ νκΈ°λ₯Ό μ 곡νλ κ²½μ°κ° λ§μ΅λλ€. μ΄λ° κ²½μ°, λ³΄ν΅ μ곑 μΈμ΄λ‘ λ κ°μ¬λ μ 곡νμ§λ§ κΈ°λ³Έ μΈμ΄λ λ‘λ§μ νκΈ° λμ΄μλ κ²½μ°κ° λ§μ΅λλ€. μλ₯Ό λ€μ΄, νκ΅μ΄ 곑μ΄λΌλ μ곑 κ°μ¬κ° μλ μμ΄ λ°μλλ‘ λ³νλ λ‘λ§μ νκΈ°κ° μ 곡λ μ μμ΅λλ€.
`Genius`μμ λΉμμ΄κΆμ λ
Έλμ κ²½μ° μ곑 μΈμ΄μ κ°μ¬λ₯Ό μ 곡νλ κ²½μ°κ° μμ§λ§, κΈ°λ³ΈμΈμ΄λ λ‘λ§μλ‘ λμ΄μλ κ²½μ°κ° λ§μ΅λλ€. κ·Έλ¬λ `Genius API`μμλ λ²μλ κ°μ¬λ₯Ό κ°μ Έμ€λ κΈ°λ₯μ μ 곡νμ§ μμμ κΈ°λ³ΈμΈμ΄λ§ κ°μ Έμ¬ μ μμ΅λλ€. λ°λΌμ `Genius API`λ§μ μ¬μ©ν κ²½μ° κΈ°λ³ΈμΈμ΄ μ΄μΈμ λ²μλ κ°μ¬λ₯Ό κ°μ Έμ€κΈ° μ΄λ €μ΄ λ¬Έμ κ° μμμ΅λλ€.
μ΄ λ¬Έμ λ₯Ό ν΄κ²°νκΈ° `Genius API` κΈ°λ°μ΄λ©΄μ μΉ ν¬λ‘€λ§ κΈ°λ₯μ μ§μνλ `genius-lyrics` λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©νμμ΅λλ€. `Topzl`μ μ€μ νμ΄μ§μμ κ°μ¬ κ²μ μ κ²μ λ°©μμΌλ‘ κΈ°λ³Έκ²μκ³Ό μ λ°κ²μμ μ 곡νλλ° κΈ°λ³Έκ²μμ `Genius API`μ κ²μμ κ·Έλλ‘ μ¬μ©ν΄μ κ²μνμ¬ λΉ λ₯Έμλλ₯Ό μ 곡νμ§λ§ λΉμμ΄κΆμ μμ
μ κ²½μ° λ‘λ§μ νκΈ°μ κ°μ¬μΌ κ°λ₯μ±μ΄ λμ΅λλ€. λ°λ©΄, μ λ° κ²μμ κ²½μ°λ `genius-lyrics`μ μΉ ν¬λ‘€λ§μ ν΅ν΄μ λ²μλ κ°μ¬λ₯Ό νμΈνκ³ λ‘λ§μ νκΈ°κ° μλ μ곑 μΈμ΄μ κ°λ₯μ±μ λμ
λλ€.
```ts
const searchMethod = await getAppConfigPath("lyric.searchMethod"); // κ²μλ°©μμ κ°μ Έμ€κΈ°
const songs = await client.songs.search(query); // κ²μμ΄λ‘ κ°μ¬ κ²μ
if (searchMethod === "basic") {
return songs[0].lyrics(false); // κΈ°λ³Έ κ²μ: κ²μ κ²°κ³Όμ 첫λ²μ§Έ κ°μ¬ λ°ν
} else {
const scrapedData = await client.songs.scrape(songs[0].url);
const scrapedSong = Object.values(scrapedData.data.entities.songs)[0]
// μ λ° κ²μ: 첫λ²μ§Έ κ²μ κ²°κ³Όλ₯Ό κΈ°μ€μΌλ‘ ν¬λ‘€λ§ ν, λ²μλ κ°μ¬λ₯Ό μ°Ύκ³ λ°ν
}
```
ν΄λΉ μμ
μ΄ λ²μλ κ°μ¬λ₯Ό μ§μνμ§ μμ μλ μμλΏλλ¬ ν¨μ¨μ±μ μν΄μ μ νλκ° κ°μ₯ λμ μ μλ 첫 λ²μ§Έ κ²μ κ²°κ³Όλ§ νμΈ νκΈ° λλ¬Έμ κ°λ₯ν λ‘λ§μ νκΈ°λ₯Ό νΌνλ μμΌλ‘ κ²μμ νμ§λ§ μ νλκ° λ¨μ΄μ§ μ μμ΅λλ€.
### 6. κ°μ μ€ν¬λ‘€ (useVirtualScroll)
```ts
type Props = {
getScrollElement: () => HTMLElement;
estimizeItemHeight: number;
data: T[];
renderCount?: number;
}
type VirtualItem = {
top: number;
rowIndex: number;
dataItem: T;
}
```
μ¬μλͺ©λ‘μ΄ λ§μμ§ κ²½μ°, μ¬μλͺ©λ‘μ ν¨μ¨μ μΈ λ λλ§μ μν΄μ κ°μ μ€ν¬λ‘€μ μ¬μ©ν©λλ€. μ΄λ `useVirtualScroll`λΌλ ν
μ μ¬μ©νμ¬ μλ³Έ μ¬μλͺ©λ‘μ `VirtualItem`νμ
λ°°μ΄λ‘ λ°ννμ¬ νλ©΄μ λ λλ§ν νλͺ©μ κ΄λ¦¬ν©λλ€. `Props`λ‘λ μ€ν¬λ‘€ μ΄λ²€νΈλ₯Ό μΆμ νκΈ° μν μ€ν¬λ‘€ν `DOM`μμλ₯Ό refλ‘ μ λ¬λ°κ³ , κ° λ¦¬μ€νΈμ μμ λμ΄, λ°μ΄ν° λ°°μ΄, μ ννκΈ° μν κ°μλ₯Ό μ λ¬λ°μ΅λλ€.
```tsx
const virtualController = useVirtualScroll(props)
return (
{virtualController.virtualItems.map(({rowIndex, top}) => {
return (
{/* κ° νλͺ© λ΄μ© */}
);
})}
)
```
`useVirtualScroll`μΌλ‘ λ°νλ `VirtualItem[]` νμ
μ λ°°μ΄μ λ°ννλ©°, κ° νλͺ©μ `top`μμΉλ₯Ό κΈ°μ€μΌλ‘ λ λλ§ λ©λλ€.
μ 체 λμ΄λ₯Ό μ€μ νκ³ λ λλ§μ μ νν κ°μλ§νΌ νλͺ©λ€μ νλ©΄μ λ λλ§νμ¬, λ§μ λ°μ΄ν°κ° μμκ²½μ° νλ©΄μ ν λ²μ λ λλ§λλ νλͺ©μ μ νν¨μΌλ‘μ¨ ν¨μ¨μ μΌλ‘ λ λλ§ ν μ μμ΅λλ€.
### 7. μ¬μλͺ©λ‘ μ λ ¬ (Drag & Drop)
μ¬μλͺ©λ‘ λ° μ¬μλͺ©λ‘ ν
μ΄λΈμμ κ° νλͺ©μ λλκ·Έ λλμΌλ‘ μμΉλ₯Ό λ°κΎΈλ κΈ°λ₯μ μ§μν©λλ€.
#### ꡬν μ리
1. `position: absolute` μμ±μ μ¬μ©νμ¬ κ° νλͺ©μ μ λλ μλμ λλ‘ κ°λ₯ν μμ `Droppable` μμμ μμ±ν©λλ€.
2. μ¬μ©μκ° νλͺ©μ λλκ·Έλ₯Ό μμνλ©΄ `dataTransfer`λ₯Ό νμ©ν΄ λλκ·Έκ° μμλ νλͺ©μ `index`λ₯Ό μ μ₯ν©λλ€.
3. `Droppable` μμμ `onDrop` μ΄λ²€νΈκ° λ°μνλ©΄, λλκ·Έ μμ `index`μ λλ‘λ μμΉμ `index`λ₯Ό λΉκ΅νμ¬ λ°°μ΄μμ μμλ₯Ό λ³κ²½ν©λλ€.
```tsx
const Droppable = (props) => {
const [isDragOver, setIsDragOver] = useState(false);
return (
{
e.preventDefault();
setIsDragOver(true); // λλκ·Έ κ°λ₯ν μμμ λ€μ΄μμμ νμ
}}
onDragLeave={()=> setIsDragOver(false)} // μμμ λ²μ΄λ¨
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
// λλκ·Έ μμ indexμ λλ‘λ indexλ₯Ό λΉκ΅νμ¬ μμ λ³κ²½
}}
>
{isDragOver && {/* λλκ·Έ κ°λ₯ν μμ UI */} }
)
}
```
νμ¬ μ¬μλͺ©λ‘μ `useVirtualScroll`λ‘ λνλ κ°μ²΄λ₯Ό μ¬μ©νκ³ μκΈ°μ λ°°μ΄μ `index`κ°μλ `VirtualItem`μ `rowIndex`λ₯Ό μ¬μ©ν΄μΌ κ°μ μ€ν¬λ‘€μ μ¬μ©νλ©΄μ μ μμ μΌλ‘ λλκ·Έ μ€ λλκΈ°λ₯μ΄ λμν©λλ€.
### 8. Scroll Navigator
μ¬μ©μκ° μ€ν¬λ‘€ν λ νμ¬ λ³΄κ³ μλ μΉμ
μ μλμΌλ‘ κ°μ§νμ¬ λ€λΉκ²μ΄μ
`UI`λ₯Ό μ
λ°μ΄νΈνλ μΈν°νμ΄μ€λ₯Ό `Scrollspy`λΌκ³ ν©λλ€. μ΄ κ³Όμ μμ `IntersectionObserver`λ₯Ό μ¬μ©νλ©΄ μ½κ² ꡬνν μ μμ΅λλ€.
#### ꡬν μ리
1. νμ¬ μ νλ μΉμ
μ κ΄λ¦¬νλ `state`λ₯Ό μμ±ν©λλ€.
2. κ° μΉμ
μ λ§ν¬μ
ν λ κ³ μ ν `id`λ₯Ό λΆμ¬ν©λλ€.
3. `IntersectionObserver`λ₯Ό μ¬μ©νμ¬ κ° μΉμ
μ΄ λ·°ν¬νΈμ κ΅μ°¨λλ λΉμ¨μ μΆμ ν©λλ€.
4. κ°μ₯ λ§μ΄ κ΅μ°¨λ μΉμ
μ `state`λ‘ μ
λ°μ΄νΈνμ¬ `Scrollspy`λ₯Ό λμ μΌλ‘ λ³κ²½ν©λλ€.
```ts
const [selected, setSelected] = useState(routers[0].id);
const intersectionObserverRef = useRef();
const intersectionRatioRef = useRef>(new Map());
useEffect(() => {
const ratioMap = intersectionRatioRef.current;
intersectionObserverRef.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
ratioMap.set(entry.target.id, entry.intersectionRatio);
});
// ratioMapμμ κ°μ₯ λμ λΉμ¨μ μΉμ
μΌλ‘ μνλ₯Ό λ³κ²½
},
options
);
// μ΅μ λ² λ±λ‘ (κ° μΉμ
κ°μ μμ)
}, []);
```
μ΄λ κ² νλ©΄ μ€ν¬λ‘€ μ κ°μ₯ λ§μ΄ κ΅μ°¨λ μΉμ
μ΄ μλμΌλ‘ μ νλλ©°, μ΄λ₯Ό κΈ°λ°μΌλ‘ μ€ν¬λ‘€ μμΉμ λ°λΌμ `Scrollspy`λ₯Ό μ
λ°μ΄νΈν μ μμ΅λλ€
### 9. Focusμ Blur μ΄λ²€νΈ νλ¦ μ μ΄
μλ¨ λ©λ΄λ ν΅μμΉλ₯Ό μν `input` νΌμ μ 곡νλ©° `focus`μ΄λ²€νΈκ° λ°μνλ©΄ `history`κ° νμλκ³ `blur` μ΄λ²€νΈκ° λ°μνλ©΄ `history`λ₯Ό λ«λ λμμ ν©λλ€. νμ§λ§ `history`κ° μ΄λ¦°μνμμ λ΄λΆλ₯Ό ν΄λ¦νλ©΄ λ΄λΆμ ν¬μ»€μ±λ³΄λ€ `input`μ `blur` μ΄λ²€νΈκ° λ¨Όμ μΌμ΄λ μ°½μ΄ λ°λ‘ λ«νκ² λμ΄, λ΄λΆμ λ²νΌμ΄ λμνμ§ μλ λ¬Έμ κ° λ°μν©λλ€.
```tsx
const isFocusedRef = useRef(false);
return (
openHistory()}
onBlur={() => {
setTimeout(() => {
if (!isFocusedRef.current) closeHistory();
});
}}
/>
{
isFocusedRef.current = true;
}}
onBlur={() => {
isFocusedRef.current = false;
closeHistory();
}}
/>
)
```
μ΄ λ¬Έμ λ₯Ό ν΄κ²°νκΈ° μν΄, `ref`λ₯Ό μμ±νμ¬ `history`κ° μ΄λ¦° μνμμ μ
λ ₯ νλμ ν¬μ»€μ€ μνλ₯Ό κ΄λ¦¬ν©λλ€. κ·Έλ¦¬κ³ `input`μ `blur` μ΄λ²€νΈμμλ `setTimeout`μ μ¬μ©ν΄ `history`λ₯Ό λ«λ λμμ λ€μ νλ μμΌλ‘ 보λ΄μ΄ `blur` μ΄λ²€νΈκ° `focus`λ³΄λ€ λ λ¦κ² λ°μνλλ‘ νμ¬ `focus` μ΄λ²€νΈκ° μ°μ μ²λ¦¬λλ κ²μ 보μ₯ν©λλ€. μ΄ κ³Όμ μμ `searchHistory`λ κΈ°λ³Έμ μΌλ‘ `focus`μ΄λ²€νΈκ° μΌμ΄λμ§ μκΈ°μ `tabindex` λ₯Ό μ¬μ©νμ¬ ν¬μ»€μ±μ΄ κ°λ₯νλλ‘ ν©λλ€.
### 10. 컨ν
μ€νΈ λ©λ΄
μ¬μλͺ©λ‘μμ λ§μ°μ€ μ°ν΄λ¦ μ μ¬μλͺ©λ‘μ μ¨λ² 컀λ²μ ν¨κ» μΆκ° κΈ°λ₯μ μ 곡νλ 컨ν
μ€νΈ λ©λ΄κ° λνλ©λλ€. μ΄λ 컨ν
μ€νΈ λ©λ΄λ νμ¬ λ§μ°μ€μ μμΉμ λ°λΌμ λ©λ΄λ₯Ό μ΄λ λ°©ν₯μΌλ‘ 보μ¬μ€μΌ ν μ§ μ νλΌμΌ ν©λλ€. λ§μ°μ€κ° μ°μΈ‘ μλ¨μμ 컨ν
μ€νΈ λ©λ΄κ° μ΄λ¦°λ€λ©΄ λ©λ΄κ° νλ©΄μμ κ°λ €μ§λ€κ±°λ μ€ν¬λ‘€μ΄ λ°μν μ μκΈ° λλ¬Έμ
λλ€.
```ts
// OFFSET: λ§μ°μ€μ λ©λ΄κ° λ무 λ± λΆμ΄μμ§ μκΈ° μν κ°κ²©
function computedPosition(x: number, y: number, count: number, padding: number) {
const MAX_HEIGHT = count * MENU_ITEM_HEIGHT + padding;
const isLeft = x < window.innerWidth / 2 ? 0 : 1;
const isTop = y < window.innerHeight / 2 ? 0 : 2;
switch (isLeft + isTop) {
case 0: // 2μ¬λΆλ©΄
return [x + OFFSET, y + OFFSET];
case 1: // 1μ¬λΆλ©΄
return [x - MENU_ITEM_WIDTH - OFFSET, y + OFFSET];
case 2: // 3μ¬λΆλ©΄
return [x + OFFSET, y - MAX_HEIGHT - OFFSET];
case 3: // 4μ¬λΆλ©΄
return [x - MENU_ITEM_WIDTH - OFFSET, y - MAX_HEIGHT - OFFSET];
}
}
```
μ¬μλͺ©λ‘μμ `onContextMenu`μ΄λ²€νΈκ° λ°μνμ λ λ§μ°μ€μ μ’νλ₯Ό κ³μ°νμ¬ νμ¬ λ·°ν¬νΈμμμ μμΉλ₯Ό κΈ°μ€μΌλ‘ λ°λ λ°©ν₯μΌλ‘ λ©λ΄κ° μ΄λ¦΄ λ°©ν₯μ κ²°μ νκ³ μ΄λ₯Ό ν΅ν΄ νμ νλ©΄ λ΄μμ λ©λ΄κ° νμλλλ‘ λ³΄μ₯ν μ μμ΅λλ€.
### 11. EventEmitter
μμ
μ¬μκ³Ό κ΄λ ¨λ μ΄λ²€νΈ λ° λ¨μΆν€ μ
λ ₯ μ΄λ²€νΈ μ²λ¦¬λ₯Ό μν΄ `eventemitter3` λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©ν©λλ€. μ΄ λΌμ΄λΈλ¬λ¦¬λ `node:events` λͺ¨λμ `EventEmitter`μ μ μ¬νμ§λ§ λΈλΌμ°μ μμλ μ¬μ©κ°λ₯νλ©° `DOM` μ΄λ²€νΈμλ λ³κ°λ‘ λ
립μ μΈ μ΄λ²€νΈ μμ€ν
μ μ 곡ν©λλ€.
```ts
// setupPlayer
import EventEmitter from "eventemitter3";
const playerEventEmitter = new EventEmitter()
playerEventEmitter.on("play-end", () => {
// κ³‘μ΄ λλ¬μλ λ°μνλ―λ‘ λ°λ³΅ λͺ¨λμ λ°λΌμ λ€μ λμμ μ²λ¦¬
});
```
```ts
// TrackPlayer
this.$audio.onended = () => {
playerEventEmitter.emit("play-end");
};
```
μμ μ½λλ `TrackPlayer`μμ κ³‘μ΄ μ’
λ£λ λ `play-end` μ΄λ²€νΈλ₯Ό λ°μμν€κ³ , μ΄λ₯Ό νΈλ€λ¬μμ μ²λ¦¬νλ λ°©μμ
λλ€. μ±μ΄ μμλ λ 미리 κ° μ΄λ²€νΈμ λν νΈλ€λ¬λ₯Ό λ§λ€μ΄λκ³ μ΄νμ νλ μ΄μ΄μμ μ΄λ²€νΈκ° λ°μνλ©΄ μ§μ μ²λ¦¬νλκ² μλ λ
립μ μΈ μ΄λ²€νΈ μμ€ν
μ νμ©νμ¬ μ΄λ²€νΈλ€μ νκ³³μμ κ΄λ¦¬νλ©° μ½λμ μ μ§λ³΄μμ±μ λμΌ μ μμ΅λλ€.
### 12. λ¨μΆν€ λ±λ‘ (In-App, Global)
μ€μ νμ΄μ§μμ μ¬μ©μκ° μ§μ λ¨μΆν€λ₯Ό 컀μ€ν
ν μ μμ΅λλ€.
- **In-App λ¨μΆν€**: μ΄ν리μΌμ΄μ
μ΄ ν¬μ»€μ€λ μνμμλ§ λμ
- **Global λ¨μΆν€**: λ°±κ·ΈλΌμ΄λμμ λ€λ₯Έ μ΄ν리μΌμ΄μ
μ¬μ© μ€μλ λμ
`In-App`λ¨μΆν€λ `hotkeys-js`λ₯Ό μ¬μ©ν΄μ λ λλ¬ νλ‘μΈμ€μμ κ΄λ¦¬νλ©°, `Global` λ¨μΆν€λ λ©μΈ νλ‘μΈμ€μμ `electron` λͺ¨λμ `globalShortcut API`λ₯Ό μ¬μ©νμ¬ μμ€ν
μ λ±λ‘ν©λλ€. λ΄λΆμ μΌλ‘λ `EventEmitter`μ μ¬μ©νμ¬ κ° κΈ°λ₯λ€μ λν νΈλ€λ¬λ₯Ό λ±λ‘ν΄ λκ³ μ΄ν μ¬μ©μκ° νΉμ λ¨μΆν€λ₯Ό μ€μ νλ©΄, `keydown` μ΄λ²€νΈ λ°μ μ ν΄λΉ νΈλ€λ¬κ° μ€νλλ λ°©μμΌλ‘ λμν©λλ€.
### 13. λ‘컬 λ°μ΄ν° κ΄λ¦¬
νμ¬ λ©μΈ νλ‘μΈμ€μ λ λλ¬ νλ‘μΈμ€μμ λ‘컬 λ°μ΄ν°λ₯Ό κ΄λ¦¬νκΈ° μν΄μ μΈ κ°μ§ λ°©μμ μ¬μ©ν©λλ€.
- **JSON νμΌ (λ©μΈ νλ‘μΈμ€)**
- **λ‘컬 μ€ν λ¦¬μ§ (λ λλ¬ νλ‘μΈμ€)**
- **IndexedDB (λ λλ¬ νλ‘μΈμ€)**
λͺ¨λ μ¬μ©μ μ€μ μ `Windows` νκ²½ κΈ°μ€μΌλ‘ `AppData\Roaming\topzl\config.json` νμΌμμ κ΄λ¦¬λμ΄ λ©μΈ νλ‘μΈμ€μ λ λλ¬ νλ‘μΈμ€μμ μ€μ λ°μ΄ν°λ₯Ό 곡μ λ©λλ€. νμ¬ μ¬μ μ€μΈ 곑, λ³Όλ₯¨, μ¬μμλ, λ°λ³΅λͺ¨λ, μ
νλͺ¨λ λ±μ λΉκ΅μ νλ°μ± λ°μ΄ν°μ κ°κΉκ³ μ’ λ λ¨μν νμ
μ λ°μ΄ν°λ μ€ν 리μ§λ‘ κ΄λ¦¬ν©λλ€. λ°λ©΄ νμ¬ μ¬μλͺ©λ‘, μ 체 μ¬μλͺ©λ‘ λ±μ 컬λ μ
λ°μ΄ν° μ²λΌ λμ©λμ΄ λ μ μλ λ°μ΄ν°λ `IndexedDB`λ₯Ό μ¬μ©νμ¬ κ΄λ¦¬ν©λλ€.
### 14. νλ©΄ μΊ‘μ²
`electron` λͺ¨λμ `desktopCapturer API`λ₯Ό μ¬μ©νλ©΄ νμ¬ μ¬μ©μμ μ 체 νλ©΄ λλ νΉμ μλμ°μ°½μ κ³ μ ν μλ³μ `id`λ₯Ό κ°μ Έμ¬ μ μμ΅λλ€. μ΄ μλ³μλ₯Ό μ΄μ©νμ¬ λΈλΌμ°μ μμ ν΄λΉ νλ©΄ λλ μλμ°μ°½μ μ€νΈλ¦¬λ° ν μ μλ `MediaStream`λ₯Ό μ»μ μ μλλ° μ΄ μ€νΈλ¦Ό λ°μ΄ν°λ₯Ό `` νκ·Έμμ μ¬μν μ μκ³ , ν΄λΉ μ€νΈλ¦Όμ 첫λ²μ§Έ νλ μμ `` νκ·Έμμ 그리면 νλ©΄ μΊ‘μ²λ₯Ό ꡬνν μ μμ΅λλ€.
```ts
const handleDesktopCapture = async () => {
const sourceId = await window.common.getDesktopCaptureId();
const stream = await navigator.mediaDevices.getUserMedia({
audio: false,
video: {
mandatory: { chromeMediaSource: "desktop", chromeMediaSourceId: sourceId },
}
});
$video.srcObject = stream;
$video.onloadedmetadata = () => {
$video.play();
drawCanvas();
};
};
const drawCanvas = () => {
const ctx = $canvas.getContext("2d");
ctx?.drawImage($video, 0, 0);
};
```
[main-screenshot]: ./.imgs/main.png
[detail-screenshot]: ./.imgs/detail.png
[download-screenshot]: ./.imgs/download.png
[library-screenshot]: ./.imgs/library.png
[local-screenshot]: ./.imgs/local.png
[pipmode-screenshot]: ./.imgs/pipmode.png
[playlist-screenshot]: ./.imgs/playlist.png
[search-screenshot]: ./.imgs/search.png
[seach_album-screenshot]: ./.imgs/seach_album.png
[settings1-screenshot]: ./.imgs/settings1.png