1+
2+
3+ const { navbarDiv, pageDiv} = ExtensionFunctions . insertNavigationItem ( {
4+ pageId : "recognition" ,
5+ icon : "F298" ,
6+ text : "识曲" ,
7+ appendBefore : "downloadPage" ,
8+ } ) ;
9+
10+ ExtensionFunctions . insertStyle ( `
11+ #recognition .title{font-size:1.75em;margin-bottom:30px;}
12+ #recognition .title>small{opacity:.8;font-size:.7em;display:block;}
13+
14+ #recognition .source{background:white;border-radius:25px;height:50px;display:flex;align-items:center;padding:5px;margin:35px 0;position:relative;}
15+ #recognition .source>div:not(.indicator){width:130px;text-align:center;transition:color .2s;}
16+ #recognition .source>div:not(.indicator):hover{color:#1E9FFF;}
17+ #recognition .source>div.indicator{position:absolute;top:5px;left:5px;bottom:5px;width:130px;transition:left .2s;color:#1E9FFF;border-radius:20px;background:#DAEFFF;}
18+ #recognition .source>div{display:flex;align-items:center;justify-content:center;}
19+ #recognition .source>div>i{margin-right:5px;margin-top:1px;}
20+ #recognition:not(.idle) .source{pointer-events:none;}
21+
22+ #recognition #recEntryPage{position:absolute;inset:0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;}
23+ #recognition #recEntryPage .recognitionBtn{width:200px;height:200px;border-radius:50%;position:relative;transition:all .2s;}
24+ #recognition #recEntryPage .recognitionBtn:active{transform:scale(.95);}
25+
26+ #recognition #recEntryPage .recognitionBtn .animationDots{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;}
27+ #recognition #recEntryPage .recognitionBtn .animationDots>div{width:12px;background:white;height:40px;border-radius:6px;transition:all .2s;margin:0 6px;}
28+ #recognition #recEntryPage .recognitionBtn .text{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;font-size:2em;transition:all .2s;opacity:0;}
29+
30+ #recognition.idle #recEntryPage .recognitionBtn .animationDots>div:nth-child(2),#recognition.idle #recEntryPage .recognitionBtn .animationDots>div:nth-child(4){height:70px;}
31+ #recognition.idle #recEntryPage .recognitionBtn .animationDots>div:nth-child(3){height:100px;}
32+
33+ #recognition.recording #recEntryPage .recognitionBtn,#recognition.loading #recEntryPage .recognitionBtn{animation:recBtnAnimation 1s ease infinite;animation-delay:.2s;}
34+ #recognition.recording #recEntryPage .recognitionBtn .animationDots>div,#recognition.loading #recEntryPage .recognitionBtn .animationDots>div:not(.centerDot){animation:recDotAnimation 1s ease-in-out infinite;}
35+ #recognition.recording #recEntryPage .recognitionBtn .animationDots>div:nth-child(2){animation-delay:.1s;}
36+ #recognition.recording #recEntryPage .recognitionBtn .animationDots>div:nth-child(3){animation-delay:.2s;}
37+ #recognition.recording #recEntryPage .recognitionBtn .animationDots>div:nth-child(4){animation-delay:.3s;}
38+ #recognition.recording #recEntryPage .recognitionBtn .animationDots>div:nth-child(5){animation-delay:.4s;}
39+ @keyframes recBtnAnimation{from{box-shadow:0 0 0 0 #1E9FFF33}100%{box-shadow:0 0 0 40px #1E9FFF00}}
40+ @keyframes recDotAnimation{50%{height:80px}100%{height:40px}}
41+
42+ #recognition.loading #recEntryPage .recognitionBtn{filter:none!important;pointer-events:none;}
43+ #recognition.loading #recEntryPage .recognitionBtn .animationDots>div{opacity:0;}
44+ #recognition.loading #recEntryPage .recognitionBtn .text{opacity:1;animation:recTextAnimation 1s linear infinite;animation-delay:.2s;}
45+ @keyframes recTextAnimation{50%{opacity:.7}100%{opacity:1}}
46+
47+ #recognition #recResultPage,#recognition #recEntryPage{transition:all .3s;}
48+ #recognition:not(.result) #recResultPage,#recognition.result #recEntryPage{transform:translateY(50vh);opacity:0;pointer-events:none;}
49+ #recognition #recResultPage{position:absolute;inset:0 0 50px 0;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:left;}
50+ #recognition #recResultPage>img{box-shadow:0 20px 25px -5px rgba(0,0,0,.1),0 8px 10px -6px rgba(0,0,0,.1);width:300px;height:300px;border-radius:10px;}
51+ #recognition #recResultPage .title>i{transition:all .2s;}
52+ #recognition #recResultPage .title>i:hover{color:#1E9FFF;}
53+ #recognition #recResultPage .title>i:active{color:#1E9FFF;opacity:.8;}
54+ #recognition #recResultPage .songInfo{width:300px;margin:20px 0 25px 0;}
55+ #recognition #recResultPage .songInfo>*{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;width:100%;}
56+ #recognition #recResultPage .songInfo>b{font-size:1.3em;}
57+ #recognition #recResultPage .songInfo>div{font-size:.9em;opacity:.8;}
58+ ` ) ;
59+
60+ pageDiv . innerHTML = `
61+ <div id="recEntryPage">
62+ <div class="title">听歌识曲<small>识别您正在播放的歌曲</small></div>
63+ <button class="recognitionBtn">
64+ <div class="animationDots"><div></div><div></div><div></div><div></div><div></div></div>
65+ <div class="text">正在识别</div>
66+ </button>
67+ <div class="source">
68+ <div class="indicator"></div>
69+ <div><i></i>系统音频</div>
70+ <div><i></i>环境音频</div>
71+ </div>
72+ </div>
73+ <div id="recResultPage">
74+ <div class="title"><i></i> 识别结果</div>
75+ <img src="assets/placeholder.svg">
76+ <div>
77+ <div class="songInfo">
78+ <b class="musicTitle">未知曲目</b>
79+ <div>
80+ <i></i> <span class="artist">未知艺术家</span>
81+ <i></i> <span class="album">未知专辑</span>
82+ </div>
83+ </div>
84+ </div>
85+ <div class="buttonGroup">
86+ <button class="searchBtn"><i></i> 在「<span class="searchSource"></span>」搜索</button>
87+ <button class="sub square searchOptionBtn"><i></i></button>
88+ </div>
89+ </div>
90+ ` ;
91+
92+
93+
94+
95+ let recSource = 0 ;
96+ let recRecording = false ;
97+ let mediaRecorder , stream , audioChunks ;
98+ function recSwitchSource ( id ) {
99+ recSource = id ;
100+ const indicator = pageDiv . querySelector ( ".source>div.indicator" ) ;
101+ indicator . style . left = `${ id * 130 + 5 } px` ;
102+ setTimeout ( ( ) => { indicator . innerHTML = pageDiv . querySelector ( `.source>div:nth-child(${ id + 2 } )` ) . innerHTML ; } , 150 ) ;
103+ } ;
104+ recSwitchSource ( 0 ) ;
105+ pageDiv . querySelector ( `.source>div:nth-child(2)` ) . onclick = ( ) => { recSwitchSource ( 0 ) ; } ;
106+ pageDiv . querySelector ( `.source>div:nth-child(3)` ) . onclick = ( ) => { recSwitchSource ( 1 ) ; } ;
107+ function resetStatus ( ) {
108+ pageDiv . classList . add ( "idle" ) ;
109+ pageDiv . classList . remove ( "recording" ) ;
110+ pageDiv . classList . remove ( "loading" ) ;
111+ pageDiv . classList . remove ( "result" ) ;
112+ recRecording = false ;
113+ try {
114+ mediaRecorder . stop ( ) ;
115+ stream . getTracks ( ) . forEach ( track => track . stop ( ) ) ;
116+ } catch { }
117+ }
118+ resetStatus ( ) ;
119+ pageDiv . querySelector ( ".title>i" ) . onclick = resetStatus ;
120+
121+ pageDiv . querySelector ( ".recognitionBtn" ) . onclick = async ( ) => {
122+ if ( ! recRecording ) {
123+ pageDiv . classList . remove ( "idle" ) ;
124+ pageDiv . classList . add ( "recording" ) ;
125+ if ( ! document . getElementById ( "audio" ) . paused ) SimAPControls . togglePlay ( ) ;
126+ recRecording = true ;
127+ // 开始录音逻辑
128+ try {
129+ // 说明:getDisplayMedia无法设置只读音频源(video must be requested) 只能拉小视频码率
130+ stream = recSource ? await navigator . mediaDevices . getUserMedia ( { audio : true , video : false } ) : await navigator . mediaDevices . getDisplayMedia ( { audio : true } ) ;
131+ audioChunks = [ ] ;
132+ mediaRecorder = new MediaRecorder ( stream , { mimeType : "video/mp4" , videoBitsPerSecond : 1 } ) ;
133+ mediaRecorder . ondataavailable = e => { if ( e . data . size > 0 ) audioChunks . push ( e . data ) ; } ;
134+ mediaRecorder . start ( ) ;
135+ } catch {
136+ alert ( "录音时出现错误,请检查相关权限配置。" ) ;
137+ resetStatus ( ) ;
138+ }
139+ } else {
140+ pageDiv . classList . remove ( "recording" ) ;
141+ pageDiv . classList . add ( "loading" ) ;
142+ recRecording = false ;
143+ // 停止录音逻辑
144+ try {
145+ mediaRecorder . onstop = async ( ) => {
146+ const audioBlob = new Blob ( audioChunks , { type : "video/mp4" } ) ;
147+ const audioFile = new File ( [ audioBlob ] , "recording.mp4" , { type : "video/mp4" } ) ;
148+ const formData = new FormData ( ) ;
149+ formData . append ( "file" , audioFile , "recording.mp4" ) ;
150+ const res = await fetch ( "https://api.doreso.com/upload" , { method : "POST" , body : formData } ) ;
151+ if ( res . ok ) {
152+ const result = await res . json ( ) ;
153+ queryResult ( result . data . id ) ;
154+ } else {
155+ alert ( `今日调用过于频繁 (${ res . status } ),请稍后再试。` ) ;
156+ resetStatus ( ) ;
157+ }
158+ } ;
159+ mediaRecorder . stop ( ) ;
160+ } catch {
161+ alert ( "出现未知错误,请稍后再试。" ) ;
162+ resetStatus ( ) ;
163+ } finally {
164+ stream . getTracks ( ) . forEach ( track => track . stop ( ) ) ;
165+ }
166+ }
167+ } ;
168+
169+ async function queryResult ( id ) {
170+ const res = await fetch ( `https://api.doreso.com/file/${ id } ` ) ;
171+ const json = await res . json ( ) ;
172+ if ( ! json ?. data [ 0 ] ?. results ) {
173+ if ( json ?. data [ 0 ] ?. state == 0 ) return setTimeout ( ( ) => { queryResult ( id ) } , 500 ) ;
174+ else {
175+ alert ( "暂未匹配到相似的曲目。" ) ;
176+ return resetStatus ( ) ;
177+ }
178+ }
179+ const title = json . data [ 0 ] . results ?. music [ 0 ] ?. result ?. title ;
180+ const artist = json . data [ 0 ] . results ?. music [ 0 ] ?. result ?. artists ?. map ( item => item . name ) . join ( ", " ) ?? "未知艺术家" ;
181+ const album = json . data [ 0 ] . results ?. music [ 0 ] ?. result ?. album ?. name ?? "未知专辑" ;
182+ if ( ! title ) {
183+ alert ( "暂未匹配到相似的曲目。" ) ;
184+ return resetStatus ( ) ;
185+ }
186+ pageDiv . querySelector ( ".musicTitle" ) . innerText = title ;
187+ pageDiv . querySelector ( ".artist" ) . innerText = artist ;
188+ pageDiv . querySelector ( ".album" ) . innerText = album ;
189+ pageDiv . querySelector ( "#recResultPage>img" ) . src = "assets/placeholder.svg" ;
190+ pageDiv . classList . add ( "result" ) ;
191+ searchKeyword = `${ title } - ${ artist } `
192+ const data = {
193+ "artist" : artist ,
194+ "album" : album ,
195+ "country" : "cn" ,
196+ "sources" : [ "kugou" ] ,
197+ } ;
198+ fetch ( "https://covers.musichoarders.xyz/api/search" , {
199+ method : "POST" ,
200+ headers : { "Content-Type" : "application/json" , } ,
201+ body : JSON . stringify ( data ) ,
202+ } )
203+ . then ( res => res . text ( ) )
204+ . then ( text => {
205+ let imageFound ;
206+ text . split ( "\n" ) . forEach ( line => {
207+ if ( imageFound ) return ;
208+ try {
209+ let json = JSON . parse ( line ) ;
210+ if ( json . type == "cover" ) {
211+ pageDiv . querySelector ( "#recResultPage>img" ) . src = json . bigCoverUrl ;
212+ imageFound = true ;
213+ }
214+ } catch { }
215+ } ) ;
216+ } ) ;
217+ }
218+
219+
220+
221+ let currentSource , searchKeyword ;
222+ function loadSearchList ( ) {
223+ ExtensionRuntime . getExtData ( ) . then ( extData => {
224+ let sources = { } ;
225+ currentSource = config . getItem ( "recognition.searchSource" ) ;
226+ for ( const name in ExtensionConfig ) {
227+ if ( ExtensionConfig [ name ] . search ) sources [ name ] = extData [ name ] . uiName ;
228+ }
229+ if ( ! sources [ currentSource ] ) currentSource = Object . keys ( sources ) [ 0 ] ;
230+ pageDiv . querySelector ( ".searchSource" ) . innerText = sources [ currentSource ] ;
231+ let menuItems = [ ] ;
232+ for ( const name in sources ) {
233+ menuItems . push ( {
234+ label : sources [ name ] ,
235+ icon : currentSource == name ? "EB7B" : " " ,
236+ click ( ) { config . setItem ( "recognition.searchSource" , name ) ; loadSearchList ( ) ; } ,
237+ } ) ;
238+ }
239+ pageDiv . querySelector ( ".searchOptionBtn" ) . onclick = e => {
240+ new ContextMenu ( menuItems ) . popup ( [ e . clientX , e . clientY ] ) ;
241+ } ;
242+ } ) ;
243+ }
244+ loadSearchList ( ) ;
245+ pageDiv . querySelector ( ".searchBtn" ) . onclick = async ( ) => {
246+ await Search . switchSearch ( ) ;
247+ document . getElementById ( "searchSource" ) . value = currentSource ;
248+ document . getElementById ( "searchInput" ) . value = searchKeyword ;
249+ Search . submit ( ) ;
250+ }
0 commit comments