An open API service indexing awesome lists of open source software.

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: 11 months ago
JSON representation

🎢Cross-platform music streaming application with Electron

Awesome Lists containing this project

README

          





Electron version







![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 structure

`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`ν†΅μ‹ μœΌλ‘œλŠ” 메인과 λ Œλ”λŸ¬κ°„μ˜ μ†Œν†΅λ§Œ κ°€λŠ₯ν•˜κΈ°μ— λ Œλ”λŸ¬ ν”„λ‘œμ„ΈμŠ€κ°„ μƒνƒœλ₯Ό κ³΅μœ ν•˜κΈ° μœ„ν•΄μ„œλŠ” 메인 ν”„λ‘œμ„ΈμŠ€μ—μ„œ μƒνƒœλ₯Ό μ€‘κ°œν•΄μ•Όλ§Œ ν•©λ‹ˆλ‹€.


electron structure

ν˜„μž¬ `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) λ‹€μš΄λ‘œλ“œ 및 λ‹€μš΄λ‘œλ“œ μƒνƒœ 동기화


monitoring

μŒμ› λ‹€μš΄λ‘œλ“œ λ˜ν•œ μ›Œμ»€ μŠ€λ ˆλ“œμ—μ„œ μ‹€ν–‰λ©λ‹ˆλ‹€. λ‹€μš΄λ‘œλ“œκ°€ μ‹œμž‘λ˜λ©΄ λ Œλ”λŸ¬ ν”„λ‘œμ„ΈμŠ€λ‘œ λΆ€ν„° 전달받은 `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) 둜컬 폴더 λͺ¨λ‹ˆν„°λ§


monitoring

둜컬 νŽ˜μ΄μ§€μ—μ„  폴더λ₯Ό λ“±λ‘ν•˜κ³  κ·Έ ν΄λ”μ—μ„œ μ˜€λ””μ˜€ 파일의 메타데이터λ₯Ό μ „λΆ€ λ½‘μ•„μ„œ 리슀트둜 λ³΄μ—¬μ€λ‹ˆλ‹€. 이 κ³Όμ •μ—μ„œ `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)


monitoring

μž¬μƒλͺ©λ‘ 및 μž¬μƒλͺ©λ‘ ν…Œμ΄λΈ”μ—μ„œ 각 ν•­λͺ©μ„ λ“œλž˜κ·Έ λ“œλžμœΌλ‘œ μœ„μΉ˜λ₯Ό λ°”κΎΈλŠ” κΈ°λŠ₯을 μ§€μ›ν•©λ‹ˆλ‹€.

#### κ΅¬ν˜„ 원리

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


monitoring

μ‚¬μš©μžκ°€ μŠ€ν¬λ‘€ν•  λ•Œ ν˜„μž¬ 보고 μžˆλŠ” μ„Ήμ…˜μ„ μžλ™μœΌλ‘œ κ°μ§€ν•˜μ—¬ λ„€λΉ„κ²Œμ΄μ…˜ `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 이벀트 흐름 μ œμ–΄


monitoring

상단 λ©”λ‰΄λŠ” ν€΅μ„œμΉ˜λ₯Ό μœ„ν•œ `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)

μ„€μ • νŽ˜μ΄μ§€μ—μ„œ μ‚¬μš©μžκ°€ 직접 단좕킀λ₯Ό μ»€μŠ€ν…€ν•  수 μžˆμŠ΅λ‹ˆλ‹€.


monitoring

- **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