FindView.ets 130 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791
  1. import { SymbolGlyphModifier } from '@kit.ArkUI'
  2. import { PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils'
  3. import { BusinessError, emitter } from '@kit.BasicServicesKit'
  4. import { common } from '@kit.AbilityKit'
  5. import { CommonConstants } from '../common/constants/CommonConstants'
  6. import { EventConstants } from '../common/constants/EventConstants'
  7. import MediaTable from '../common/util/MediaTable'
  8. import { MenuModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
  9. import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSystem'
  10. import Logger from '../common/util/Logger'
  11. import { RemoteDriveManager } from '../common/util/RemoteDriveManager'
  12. import { cloneVideoItem } from '../common/util/RemotePlayerUtil'
  13. import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil'
  14. import { VideoItem } from '../viewmodel/VideoItem'
  15. import { WebDavAccount } from '../viewmodel/WebDavAccount'
  16. import { Playlist } from '../viewmodel/Playlist'
  17. import { ConfigTitle } from './ConfigTitle'
  18. import { PointLightActionButton } from './PointLight/PointLightActionButton'
  19. import { PointLightContentButton } from './PointLight/PointLightContentButton'
  20. import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton'
  21. import { DeleteComptent } from './DeleteComptent'
  22. import { SettingPage } from '../pages/SettingPage'
  23. import { FindAlbumDetail } from './FindAlbumDetail'
  24. import { PlayingIndicator } from './PlayingIndicator'
  25. import {
  26. getFindPlaylistId,
  27. getFindPlaylistName,
  28. getFindVideoItems,
  29. setFindPlaylist
  30. } from '../common/util/FindPlaylistStore'
  31. import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'
  32. import PlaylistTable from '../common/util/PlaylistTable'
  33. import { convertPlaylistSongsToVideoItems } from '../pages/PlaylistDetailPage'
  34. import { getWebdavVideoItems, setWebdavPlaylist } from '../pages/WebDavMainPage'
  35. import { getNavidromeVideoItems, setNavidromePlaylist } from '../common/util/NavidromePlaylistStore'
  36. import {
  37. buildPreferredRemotePlaybackPool,
  38. buildSortedDiscoverySongs,
  39. findPreferredRemotePlaybackAccountId,
  40. FindCollectionSortType,
  41. resolveQueueStartIndex,
  42. shouldWaitForIndexedRemotePlayback
  43. } from '../common/util/FindDiscoveryHelper'
  44. import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper'
  45. import { resolveRemoteCoverForSong } from '../common/util/RemoteCoverResolver'
  46. @Builder
  47. export function FindViewBuilder() {
  48. FindView()
  49. }
  50. const TAG = 'FindView'
  51. const SWIPER_MAX_COUNT = 5
  52. const HOT_SECTION_COUNT = 10
  53. const REMOTE_POOL_COUNT = 32
  54. const REMOTE_VISIBLE_COVER_RESOLVE_LIMIT = 24
  55. const CLOUD_SECTION_COUNT = 18
  56. const RECENT_POOL_COUNT = 24
  57. const RECENT_SECTION_COUNT = 18
  58. const POPULAR_SECTION_COUNT = 18
  59. const FAVORITE_SECTION_COUNT = 18
  60. const TOP_PLAYED_COUNT = 30
  61. const FEATURED_ALBUM_COUNT = 18
  62. const CLOUD_ALBUM_COUNT = 18
  63. const PREFERRED_ALBUM_MIN_COUNT = 6
  64. function getFindSongKey(item: VideoItem, index: number): string {
  65. if (StrUtil.isNotEmpty(item.filePath)) {
  66. return item.filePath
  67. }
  68. if (StrUtil.isNotEmpty(item.name)) {
  69. return `${item.name}_${index}`
  70. }
  71. if (StrUtil.isNotEmpty(item.fileName)) {
  72. return `${item.fileName}_${index}`
  73. }
  74. return `find_song_${index}`
  75. }
  76. interface FindAlbumGroup {
  77. id: string
  78. title: string
  79. artist: string
  80. coverPath: string
  81. songCount: number
  82. songs: VideoItem[]
  83. isRemote: boolean
  84. sourceLabel: string
  85. }
  86. interface FindSongPage {
  87. id: string
  88. items: VideoItem[]
  89. }
  90. interface FindAlbumPage {
  91. id: string
  92. items: FindAlbumGroup[]
  93. }
  94. function getFindAlbumKey(item: FindAlbumGroup, index: number): string {
  95. if (StrUtil.isNotEmpty(item.id)) {
  96. return item.id
  97. }
  98. return `find_album_${index}`
  99. }
  100. function getFindSongPageKey(item: FindSongPage, index: number): string {
  101. if (StrUtil.isNotEmpty(item.id)) {
  102. return item.id
  103. }
  104. return 'find_song_page_' + index
  105. }
  106. function getFindPlaylistKey(item: Playlist, index: number): string {
  107. if (StrUtil.isNotEmpty(item.id)) {
  108. return item.id
  109. }
  110. return 'find_playlist_' + index
  111. }
  112. function getFindAlbumPageKey(item: FindAlbumPage, index: number): string {
  113. if (StrUtil.isNotEmpty(item.id)) {
  114. return item.id
  115. }
  116. return 'find_album_page_' + index
  117. }
  118. @Component
  119. export struct FindView {
  120. @StorageProp('isLandscape') isLandscape: boolean = false;
  121. @State isCustomizeBg: boolean = false //自定义背景界面
  122. @State isHomeBgFollowMusicCover: boolean = true //主页背景随当前播放封面
  123. @State blurValue: number = 0 //背景模糊
  124. @State bgBrightness: number = 0 //背景亮度
  125. @State customizeBgPath: string | undefined = '';
  126. @State isShowTitleBar: boolean = true //是否显示分类导航条
  127. private prevOffsetY: number = 0; // 记录上一次的Y轴偏移量
  128. @State autoHideTitle: boolean = true //滚动自动隐藏标题栏
  129. @Consume mType: number
  130. @Consume isShowDrawer: boolean
  131. @Consume offsetX: number
  132. @State private swiperSongs: VideoItem[] = []
  133. @State private hotSongs: VideoItem[] = []
  134. @State private remoteSongs: VideoItem[] = []
  135. @State private cloudSectionPages: FindSongPage[] = []
  136. @State private recentSongs: VideoItem[] = []
  137. @State private popularSongs: VideoItem[] = []
  138. @State private popularSectionPages: FindSongPage[] = []
  139. @State private favoriteSongs: VideoItem[] = []
  140. @State private cloudMoodSongs: VideoItem[] = []
  141. @State private heartPlaylists: Playlist[] = []
  142. @State private featuredAlbums: FindAlbumGroup[] = []
  143. @State private featuredAlbumPages: FindAlbumPage[] = []
  144. @State private cloudAlbums: FindAlbumGroup[] = []
  145. @State private cloudAlbumPages: FindAlbumPage[] = []
  146. @State @Watch('syncFindCanBackState') private isSearchMode: boolean = false
  147. @State @Watch('syncFindCanBackState') private isAlbumMode: boolean = false
  148. @State private currentAlbumId: string = ''
  149. @State private currentAlbumTitle: string = ''
  150. @State private currentAlbumArtist: string = ''
  151. @State private currentAlbumCoverPath: string = ''
  152. @State private currentAlbumSourceLabel: string = ''
  153. @State private currentAlbumSongs: VideoItem[] = []
  154. @State private currentAlbumDefaultSortType: number = FindCollectionSortType.TRACK_ASC
  155. @State private currentAlbumSupportRecentPlayedSort: boolean = false
  156. @State private searchText: string = ''
  157. @State private searchResults: VideoItem[] = []
  158. @State private searchHistoryItems: string[] = []
  159. @State private isSearchLoading: boolean = false
  160. @State private isRefreshing: boolean = false
  161. @State private isPageLoading: boolean = true
  162. @State private refreshText: string = '加载中...'
  163. @State private swiperIndex: number = 0
  164. @State private cloudSectionPageIndex: number = 0
  165. @State private featuredAlbumPageIndex: number = 0
  166. @State private cloudAlbumPageIndex: number = 0
  167. @State private recentSectionPageIndex: number = 0
  168. @State private popularSectionPageIndex: number = 0
  169. @State private favoriteSectionPageIndex: number = 0
  170. @State private refreshPullRatio: number = 1
  171. @State private maxRefreshingHeight: number = 100
  172. @State private pendingDeleteSongs: VideoItem[] = []
  173. searchController: SearchController = new SearchController()
  174. @StorageProp('currentBreakpoint') @Watch('onDiscoverBreakpointChange') currentBreakpoint: string = BreakpointTypeEnum.MD
  175. @StorageProp('windowWidth') windowWidth: number = 0
  176. @StorageProp('topSafeHeight') topSafeHeight: number = 0
  177. @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0
  178. @StorageProp('isDarkMode') isDarkMode: boolean = false
  179. @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
  180. @StorageProp('customizeMusicBgPath') customizeMusicBgPath: string = ''
  181. @StorageLink('currentSong') @Watch('onHomeBgCurrentSongChange') currentSong: VideoItem | undefined = undefined
  182. @StorageLink('isPlaying') isPlaying: boolean = false
  183. private mediaTable?: MediaTable
  184. private playlistTable?: PlaylistTable
  185. private remoteDriveManager: RemoteDriveManager = RemoteDriveManager.getInstance()
  186. private localSongsPool: VideoItem[] = []
  187. private coveredSongsPool: VideoItem[] = []
  188. private remoteSongsPool: VideoItem[] = []
  189. private recentSongsPool: VideoItem[] = []
  190. private topPlayedSongsPool: VideoItem[] = []
  191. private favoriteSongsPool: VideoItem[] = []
  192. private featuredAlbumsPool: FindAlbumGroup[] = []
  193. private cloudAlbumsPool: FindAlbumGroup[] = []
  194. private searchRemoteSongsPool: VideoItem[] = []
  195. private remotePlaybackPool: VideoItem[] = []
  196. private readonly searchHistoryScope: string = 'find_music'
  197. private searchTicket: number = 0
  198. private cloudSectionPageVersion: number = 0
  199. private featuredAlbumPageVersion: number = 0
  200. private cloudAlbumPageVersion: number = 0
  201. private popularSectionPageVersion: number = 0
  202. private isRemoteBootstrapLoading: boolean = false
  203. private playlistSongsCache: Map<string, VideoItem[]> = new Map<string, VideoItem[]>()
  204. private heartPlaylistCoverTicket: number = 0
  205. private deleteComponentId: number = 0
  206. private remoteAccountMap: Map<string, WebDavAccount> = new Map<string, WebDavAccount>()
  207. private remoteCoverRepairTicket: number = 0
  208. private remoteCoverRepairTimer: number = -1
  209. aboutToAppear(): void {
  210. this.initSetting()
  211. this.syncFindCanBackState()
  212. let eventSetting: emitter.InnerEvent = { eventId: 333 }
  213. // 监听广播事件(通用设置配置更新)
  214. emitter.on(eventSetting, (eventData: emitter.EventData) => {
  215. this.initSetting()
  216. });
  217. emitter.on({ eventId: EventConstants.EVENT_FIND_VIEW_BACK }, () => {
  218. this.handleInnerBack()
  219. })
  220. this.ensureMediaTable()
  221. }
  222. aboutToDisappear(): void {
  223. AppStorage.setOrCreate('findCanBack', false)
  224. emitter.off(EventConstants.EVENT_FIND_VIEW_BACK)
  225. this.clearPendingRemoteCoverRepair()
  226. }
  227. initSetting(){
  228. this.isCustomizeBg = PreferencesUtil.getBooleanSync(SettingPage.IS_CUSTOMIZE_BG, false)
  229. this.isHomeBgFollowMusicCover = PreferencesUtil.getBooleanSync(SettingPage.IS_HOME_BG_FOLLOW_MUSIC_COVER, true)
  230. this.customizeBgPath = PreferencesUtil.getStringSync(SettingPage.IS_CUSTOMIZE_BG_PATH, '')
  231. this.blurValue = PreferencesUtil.getNumberSync(SettingPage.CUSTOMIZE_BG_BLUR, 0)
  232. this.bgBrightness = PreferencesUtil.getNumberSync(SettingPage.BG_BRIGHTNESS, 0)
  233. this.isShowTitleBar = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_TITLTBAR, true)
  234. this.autoHideTitle = PreferencesUtil.getBooleanSync('autoHideTitle', true)
  235. this.syncCustomizeMusicBgPath()
  236. }
  237. private onHomeBgCurrentSongChange(): void {
  238. this.syncCustomizeMusicBgPath()
  239. }
  240. private syncCustomizeMusicBgPath(): void {
  241. if (!this.isHomeBgFollowMusicCover) {
  242. return
  243. }
  244. const coverPath: string = this.currentSong?.pixelMapPath ?? ''
  245. AppStorage.setOrCreate('customizeMusicBgPath', coverPath)
  246. }
  247. private isMusicCoverHomeBackgroundActive(): boolean {
  248. return this.isHomeBgFollowMusicCover && this.customizeMusicBgPath.length > 0
  249. }
  250. private shouldUseDiscoveryImageBackgroundOverlay(): boolean {
  251. return this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive()
  252. }
  253. private getDiscoveryTopBarBackgroundColor(): ResourceColor | string {
  254. if (!this.isSearchMode && !this.isAlbumMode) {
  255. return Color.Transparent
  256. }
  257. if (this.shouldUseDiscoveryImageBackgroundOverlay()) {
  258. return this.isDarkMode ? '#66111111' : '#66FFFFFF'
  259. }
  260. return $r('app.color.start_window_background_blur')
  261. }
  262. private getDiscoverySearchBackgroundColor(): ResourceColor | string {
  263. if (this.isMusicCoverHomeBackgroundActive()) {
  264. return this.isDarkMode ? '#80111111' : '#80FFFFFF'
  265. }
  266. if (this.isCustomizeBg) {
  267. return this.isDarkMode ? '#66111111' : '#73FFFFFF'
  268. }
  269. return $r('app.color.input_background')
  270. }
  271. private onDiscoverBreakpointChange(): void {
  272. Logger.info(TAG, `FindView onDiscoverBreakpointChange breakpoint=${this.currentBreakpoint}, columns=${this.getDiscoverSectionColumns()}`)
  273. this.rebuildPagedSectionSources()
  274. }
  275. private ensureMediaTable(): void {
  276. if (!this.playlistTable) {
  277. try {
  278. this.playlistTable = new PlaylistTable(getContext(this) as common.Context)
  279. } catch (error) {
  280. Logger.warn(TAG, `初始化歌单数据库失败: ${this.toErrorMessage(error as Object)}`)
  281. }
  282. }
  283. if (this.mediaTable) {
  284. void this.loadDiscoveryContent(false)
  285. return
  286. }
  287. try {
  288. this.mediaTable = new MediaTable(getContext(this), () => {
  289. void this.loadDiscoveryContent(false)
  290. })
  291. } catch (error) {
  292. const message = this.toErrorMessage(error)
  293. Logger.error(TAG, `初始化媒体数据库失败: ${message}`)
  294. this.refreshText = '推荐加载失败,请下拉重试'
  295. this.isPageLoading = false
  296. }
  297. }
  298. private async loadDiscoveryContent(triggeredByRefresh: boolean): Promise<void> {
  299. if (!this.mediaTable) {
  300. return
  301. }
  302. if (triggeredByRefresh) {
  303. this.isRefreshing = true
  304. this.refreshText = '正在刷新'
  305. } else if (this.isPageLoading) {
  306. this.refreshText = '加载中...'
  307. }
  308. try {
  309. const allSongs = await this.mediaTable.queryAllVideos()
  310. const uniqueLocalSongs = this.filterUniqueSongs(allSongs)
  311. const coveredSongs = this.filterSongsWithCover(uniqueLocalSongs)
  312. const requestResults = await Promise.all([
  313. this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT),
  314. this.mediaTable.queryRecentPlayedRecordsAsync(RECENT_POOL_COUNT),
  315. this.queryTopPlayedSongs(TOP_PLAYED_COUNT),
  316. this.queryFavoriteSongs(),
  317. this.queryPlaylists()
  318. ])
  319. const remoteSongs = requestResults[0] as VideoItem[]
  320. const recentSongs = requestResults[1] as VideoItem[]
  321. const topPlayedSongs = requestResults[2] as VideoItem[]
  322. const favoriteSongs = requestResults[3] as VideoItem[]
  323. const playlists = requestResults[4] as Playlist[]
  324. const uniqueRemoteSongs = this.filterUniqueSongs(remoteSongs)
  325. const uniqueRecentSongs = this.filterUniqueSongs(recentSongs)
  326. const uniqueTopPlayedSongs = this.filterUniqueSongs(topPlayedSongs)
  327. const uniqueFavoriteSongs = this.filterUniqueSongs(favoriteSongs)
  328. const sortedRecentSongs = buildSortedDiscoverySongs(uniqueRecentSongs, FindCollectionSortType.RECENT_DESC)
  329. const sortedFavoriteSongs = buildSortedDiscoverySongs(uniqueFavoriteSongs, FindCollectionSortType.NAME_ASC)
  330. this.playlistSongsCache.clear()
  331. this.localSongsPool = uniqueLocalSongs
  332. this.coveredSongsPool = coveredSongs
  333. this.remotePlaybackPool = []
  334. this.applyRemoteDiscoverySongs(uniqueRemoteSongs)
  335. this.recentSongsPool = sortedRecentSongs
  336. this.topPlayedSongsPool = uniqueTopPlayedSongs
  337. this.favoriteSongsPool = sortedFavoriteSongs
  338. this.heartPlaylists = playlists
  339. this.featuredAlbumsPool = this.buildAlbumGroups(this.localSongsPool, false)
  340. this.searchRemoteSongsPool = []
  341. this.swiperSongs = this.pickRandomSongs(this.coveredSongsPool, SWIPER_MAX_COUNT)
  342. this.hotSongs = this.pickPreferredSongs(this.localSongsPool, HOT_SECTION_COUNT)
  343. this.recentSongs = this.recentSongsPool.slice(0, Math.min(RECENT_SECTION_COUNT, this.recentSongsPool.length))
  344. this.popularSongs = this.pickPreferredSongs(this.topPlayedSongsPool, POPULAR_SECTION_COUNT)
  345. this.favoriteSongs = this.favoriteSongsPool.slice(0, Math.min(FAVORITE_SECTION_COUNT, this.favoriteSongsPool.length))
  346. this.featuredAlbums = this.pickAlbumGroups(this.featuredAlbumsPool, FEATURED_ALBUM_COUNT)
  347. this.swiperIndex = 0
  348. this.resetPagedSectionIndices()
  349. this.rebuildPagedSectionSources()
  350. this.refreshText = ''
  351. this.heartPlaylistCoverTicket += 1
  352. void this.resolveHeartPlaylistCovers(this.heartPlaylistCoverTicket, playlists)
  353. if (uniqueRemoteSongs.length > 0) {
  354. void this.prepareRemoteAccounts()
  355. this.scheduleVisibleRemoteCoverRepair(false, 1200)
  356. } else {
  357. void this.bootstrapRemoteDiscoverySongsIfNeeded()
  358. }
  359. Logger.info(
  360. TAG,
  361. `[remote-debug] loadDiscoveryContent rawRemote=${remoteSongs.length}, uniqueRemote=${uniqueRemoteSongs.length}, ` +
  362. `sample=${this.buildSongDebugLog(uniqueRemoteSongs)}`
  363. )
  364. Logger.info(
  365. TAG,
  366. `发现页加载完成: local=${this.localSongsPool.length}, swiper=${this.swiperSongs.length}, remote=${this.remoteSongs.length}, recent=${this.recentSongs.length}, top=${this.topPlayedSongsPool.length}`
  367. )
  368. } catch (error) {
  369. const message = this.toErrorMessage(error)
  370. Logger.error(TAG, `发现页加载失败: ${message}`)
  371. this.swiperSongs = []
  372. this.hotSongs = []
  373. this.cloudMoodSongs = []
  374. this.remoteSongs = []
  375. this.cloudSectionPages = []
  376. this.recentSongs = []
  377. this.popularSongs = []
  378. this.popularSectionPages = []
  379. this.favoriteSongs = []
  380. this.heartPlaylists = []
  381. this.localSongsPool = []
  382. this.coveredSongsPool = []
  383. this.remoteSongsPool = []
  384. this.recentSongsPool = []
  385. this.topPlayedSongsPool = []
  386. this.favoriteSongsPool = []
  387. this.featuredAlbumsPool = []
  388. this.cloudAlbumsPool = []
  389. this.searchRemoteSongsPool = []
  390. this.remotePlaybackPool = []
  391. this.featuredAlbums = []
  392. this.featuredAlbumPages = []
  393. this.cloudAlbums = []
  394. this.cloudAlbumPages = []
  395. this.refreshText = '推荐加载失败,请下拉重试'
  396. } finally {
  397. this.isRefreshing = false
  398. this.isPageLoading = false
  399. }
  400. }
  401. private async queryPlaylists(): Promise<Playlist[]> {
  402. if (!this.playlistTable) {
  403. return []
  404. }
  405. try {
  406. const playlists = await this.playlistTable.queryAllPlaylists()
  407. return playlists.filter((item: Playlist) => StrUtil.isNotEmpty(item.id) && StrUtil.isNotEmpty(item.name))
  408. } catch (error) {
  409. Logger.warn(TAG, `发现页查询歌单失败: ${this.toErrorMessage(error as Object)}`)
  410. return []
  411. }
  412. }
  413. private applyRemoteDiscoverySongs(items: VideoItem[], refreshSections: boolean = false): void {
  414. this.remoteSongsPool = items
  415. this.cloudAlbumsPool = this.buildAlbumGroups(this.remoteSongsPool, true)
  416. this.cloudMoodSongs = this.pickPreferredSongs(this.remoteSongsPool, HOT_SECTION_COUNT)
  417. this.remoteSongs = this.pickPreferredSongs(this.remoteSongsPool, CLOUD_SECTION_COUNT)
  418. this.cloudAlbums = this.pickAlbumGroups(this.cloudAlbumsPool, CLOUD_ALBUM_COUNT)
  419. this.logRemoteDiscoveryCoverState('remoteSongsPool', this.remoteSongsPool)
  420. this.logRemoteDiscoveryCoverState('漫步云端', this.remoteSongs)
  421. this.logRemoteDiscoveryCoverState('云卷云舒', this.cloudMoodSongs)
  422. if (refreshSections) {
  423. this.cloudSectionPageIndex = 0
  424. this.cloudAlbumPageIndex = 0
  425. this.updateCloudSectionPages()
  426. this.updateCloudAlbumPages()
  427. }
  428. }
  429. private cacheRemoteAccounts(accounts: WebDavAccount[]): void {
  430. this.remoteAccountMap.clear()
  431. for (let i = 0; i < accounts.length; i++) {
  432. const account = accounts[i]
  433. if (account.id !== undefined && account.id !== null) {
  434. this.remoteAccountMap.set(account.id.toString(), account)
  435. }
  436. }
  437. }
  438. private async prepareRemoteAccounts(): Promise<WebDavAccount[]> {
  439. try {
  440. const context = getContext(this) as common.Context
  441. this.remoteDriveManager.setContext(context)
  442. await this.remoteDriveManager.createWebDavTableInDB()
  443. await this.remoteDriveManager.queryWebDavAccountsFromDB()
  444. const accounts: WebDavAccount[] = this.remoteDriveManager.getAllWebDavAccounts()
  445. .filter((account: WebDavAccount) => account.id !== undefined && account.id !== null && account.id > 0)
  446. this.cacheRemoteAccounts(accounts)
  447. return accounts
  448. } catch (error) {
  449. Logger.warn(TAG, `发现页加载远程账号失败: ${this.toErrorMessage(error)}`)
  450. this.remoteAccountMap.clear()
  451. return []
  452. }
  453. }
  454. private async ensureRemoteAccountMapReady(): Promise<Map<string, WebDavAccount>> {
  455. if (this.remoteAccountMap.size > 0) {
  456. return this.remoteAccountMap
  457. }
  458. await this.prepareRemoteAccounts()
  459. return this.remoteAccountMap
  460. }
  461. private collectVisibleRemoteCoverTargets(): VideoItem[] {
  462. const targets: VideoItem[] = []
  463. targets.push(...this.remoteSongs)
  464. targets.push(...this.cloudMoodSongs)
  465. for (let index = 0; index < this.cloudAlbums.length; index += 1) {
  466. const group = this.cloudAlbums[index]
  467. if (group.songs.length > 0) {
  468. targets.push(group.songs[0])
  469. }
  470. }
  471. // 只修当前发现页真正可见的一小批远程歌曲,避免初次进入时后台任务过重。
  472. return this.filterUniqueSongs(targets).slice(0, REMOTE_VISIBLE_COVER_RESOLVE_LIMIT)
  473. }
  474. private refreshVisibleRemoteAlbumCovers(): void {
  475. for (let index = 0; index < this.cloudAlbumsPool.length; index += 1) {
  476. const coverSong = this.pickAlbumCoverSong(this.cloudAlbumsPool[index].songs)
  477. this.cloudAlbumsPool[index].coverPath = coverSong?.pixelMapPath ?? ''
  478. }
  479. for (let index = 0; index < this.cloudAlbums.length; index += 1) {
  480. const coverSong = this.pickAlbumCoverSong(this.cloudAlbums[index].songs)
  481. this.cloudAlbums[index].coverPath = coverSong?.pixelMapPath ?? ''
  482. }
  483. }
  484. private clearPendingRemoteCoverRepair(): void {
  485. if (this.remoteCoverRepairTimer >= 0) {
  486. clearTimeout(this.remoteCoverRepairTimer)
  487. this.remoteCoverRepairTimer = -1
  488. }
  489. }
  490. private scheduleVisibleRemoteCoverRepair(persistResolvedCover: boolean = false, delayMs: number = 800): void {
  491. this.clearPendingRemoteCoverRepair()
  492. const ticket = ++this.remoteCoverRepairTicket
  493. this.remoteCoverRepairTimer = setTimeout(() => {
  494. this.remoteCoverRepairTimer = -1
  495. const coverTargets = this.collectVisibleRemoteCoverTargets()
  496. if (coverTargets.length === 0) {
  497. return
  498. }
  499. Logger.info(TAG,
  500. `[cover-debug] scheduleVisibleRemoteCoverRepair ticket=${ticket}, count=${coverTargets.length}, ` +
  501. `persist=${persistResolvedCover}, delayMs=${delayMs}`)
  502. void this.resolveRemoteSongCovers(coverTargets, persistResolvedCover)
  503. .then(() => {
  504. if (ticket !== this.remoteCoverRepairTicket) {
  505. return
  506. }
  507. this.refreshVisibleRemoteAlbumCovers()
  508. this.remoteSongs = this.remoteSongs.slice()
  509. this.cloudMoodSongs = this.cloudMoodSongs.slice()
  510. this.cloudAlbums = this.cloudAlbums.slice()
  511. this.cloudSectionPages = this.cloudSectionPages.slice()
  512. this.cloudAlbumPages = this.cloudAlbumPages.slice()
  513. this.logRemoteDiscoveryCoverState('remoteSongsPool', this.remoteSongsPool)
  514. this.logRemoteDiscoveryCoverState('漫步云端', this.remoteSongs)
  515. this.logRemoteDiscoveryCoverState('云卷云舒', this.cloudMoodSongs)
  516. })
  517. .catch((error: Object) => {
  518. Logger.warn(TAG, `发现页后台修正远程封面失败: ${this.toErrorMessage(error)}`)
  519. })
  520. }, delayMs)
  521. }
  522. private async resolveRemoteSongCovers(items: VideoItem[], persistResolvedCover: boolean): Promise<VideoItem[]> {
  523. if (!items || items.length === 0) {
  524. return items
  525. }
  526. const accountMap = await this.ensureRemoteAccountMapReady()
  527. const tasks: Promise<VideoItem>[] = items.map(async (item: VideoItem): Promise<VideoItem> => {
  528. const accountId = item.webdav_account_id ?? ''
  529. const account = accountMap.get(accountId)
  530. if (!account) {
  531. return item
  532. }
  533. const resolvedCover = await resolveRemoteCoverForSong(item, account)
  534. if (StrUtil.isEmpty(resolvedCover) || item.pixelMapPath === resolvedCover) {
  535. return item
  536. }
  537. Logger.info(
  538. TAG,
  539. `[cover-debug] repairRemoteSongCover title=${this.getSongTitle(item)}, type=${item.type}, acc=${accountId}, ` +
  540. `raw=${this.sanitizeCoverValue(item.pixelMapPath)}, resolved=${this.sanitizeCoverValue(resolvedCover)}`
  541. )
  542. item.pixelMapPath = resolvedCover
  543. if (persistResolvedCover && this.mediaTable) {
  544. await this.mediaTable.saveOrUpdateWebDavItem(cloneVideoItem(item))
  545. }
  546. return item
  547. })
  548. return await Promise.all(tasks)
  549. }
  550. private async ensureRemoteDiscoverySongsAvailable(): Promise<void> {
  551. if (!this.mediaTable) {
  552. return
  553. }
  554. if (this.remoteSongsPool.length > 0 || this.cloudAlbumsPool.length > 0) {
  555. return
  556. }
  557. const remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT))
  558. Logger.info(
  559. TAG,
  560. `[remote-debug] ensureRemoteDiscoverySongsAvailable dbRemote=${remoteSongs.length}, ` +
  561. `sample=${this.buildSongDebugLog(remoteSongs)}`
  562. )
  563. if (remoteSongs.length > 0) {
  564. this.applyRemoteDiscoverySongs(remoteSongs, true)
  565. this.scheduleVisibleRemoteCoverRepair()
  566. return
  567. }
  568. await this.bootstrapRemoteDiscoverySongsIfNeeded()
  569. }
  570. private async prepareActiveRemoteAccount(fallbackSongs?: VideoItem[]): Promise<WebDavAccount | undefined> {
  571. try {
  572. const context = getContext(this) as common.Context
  573. this.remoteDriveManager.setContext(context)
  574. await this.remoteDriveManager.createWebDavTableInDB()
  575. await this.remoteDriveManager.queryWebDavAccountsFromDB()
  576. const accounts: WebDavAccount[] = this.remoteDriveManager.getAllWebDavAccounts()
  577. if (accounts.length === 0) {
  578. return undefined
  579. }
  580. const activeAccount = this.remoteDriveManager.getActivatedWebDavAccount() || accounts[0]
  581. // 云端漫游优先挑“当前发现页已有歌曲所属账号”,避免固定落到第一个账号。
  582. const preferredAccountId = findPreferredRemotePlaybackAccountId(
  583. fallbackSongs ?? [],
  584. activeAccount?.id?.toString() ?? ''
  585. )
  586. if (StrUtil.isNotEmpty(preferredAccountId)) {
  587. for (let index = 0; index < accounts.length; index += 1) {
  588. if (accounts[index].id?.toString() === preferredAccountId) {
  589. Logger.info(
  590. TAG,
  591. `[remote-debug] prepareActiveRemoteAccount selected account=${accounts[index].name}, ` +
  592. `type=${accounts[index].webType}, preferredAccountId=${preferredAccountId}, ` +
  593. `fallbackSongs=${fallbackSongs?.length ?? 0}`
  594. )
  595. return accounts[index]
  596. }
  597. }
  598. }
  599. return activeAccount
  600. } catch (error) {
  601. Logger.warn(TAG, `发现页加载远程账号失败: ${this.toErrorMessage(error)}`)
  602. return undefined
  603. }
  604. }
  605. private async queryIndexedRemotePlaybackSongs(fallbackSongs: VideoItem[] = []): Promise<VideoItem[]> {
  606. const account = await this.prepareActiveRemoteAccount(fallbackSongs)
  607. if (!account) {
  608. Logger.warn(TAG, '[remote-debug] queryIndexedRemotePlaybackSongs skip: no active account')
  609. return []
  610. }
  611. const supportsGlobalIndex = this.remoteDriveManager.supportsGlobalSearchIndexForAccount(account)
  612. const isIndexReady = supportsGlobalIndex && this.remoteDriveManager.isGlobalSearchIndexReady(account)
  613. Logger.info(
  614. TAG,
  615. `[remote-debug] queryIndexedRemotePlaybackSongs account=${account.name}, type=${account.webType}, ` +
  616. `supportsGlobalIndex=${supportsGlobalIndex}, isIndexReady=${isIndexReady}`
  617. )
  618. if (supportsGlobalIndex && !isIndexReady) {
  619. ToastUtil.showToast('云端加载,请稍候')
  620. // 账号索引未就绪时,如果 DB 里已经有远程歌曲,就先用现有歌曲兜底播放。
  621. if (!shouldWaitForIndexedRemotePlayback(isIndexReady, fallbackSongs.length)) {
  622. Logger.info(
  623. TAG,
  624. `[remote-debug] queryIndexedRemotePlaybackSongs fallbackToDb account=${account.name}, ` +
  625. `fallbackSongCount=${fallbackSongs.length}`
  626. )
  627. void this.remoteDriveManager.ensureGlobalSearchIndex(account)
  628. return []
  629. }
  630. }
  631. const indexedSongs = await this.remoteDriveManager.getGlobalSearchIndexSongs(account)
  632. const clonedSongs = indexedSongs.map((item: VideoItem) => cloneVideoItem(item))
  633. Logger.info(TAG,
  634. `发现页全量云端索引池加载完成: account=${account.name}, type=${account.webType}, indexed=${clonedSongs.length}, ` +
  635. `sample=${this.buildSongDebugLog(clonedSongs)}`)
  636. return this.filterUniqueSongs(clonedSongs)
  637. }
  638. private async bootstrapRemoteDiscoverySongsIfNeeded(): Promise<void> {
  639. if (this.isRemoteBootstrapLoading || !this.mediaTable || this.remoteSongsPool.length > 0) {
  640. return
  641. }
  642. this.isRemoteBootstrapLoading = true
  643. try {
  644. const account = await this.prepareActiveRemoteAccount()
  645. if (!account) {
  646. return
  647. }
  648. Logger.info(TAG, `发现页开始预热远程歌曲: account=${account.name}, type=${account.webType}`)
  649. const seedSongs = await this.remoteDriveManager.getDiscoverySeedSongs(account, REMOTE_POOL_COUNT)
  650. Logger.info(
  651. TAG,
  652. `[remote-debug] bootstrapRemoteDiscoverySongs seedCount=${seedSongs.length}, sample=${this.buildSongDebugLog(seedSongs)}`
  653. )
  654. if (seedSongs.length === 0) {
  655. Logger.info(TAG, `发现页远程预热未拿到歌曲: account=${account.name}`)
  656. return
  657. }
  658. let savedCount = 0
  659. for (let index = 0; index < seedSongs.length; index += 1) {
  660. const seedSong = cloneVideoItem(seedSongs[index])
  661. if (!seedSong.webdav_account_id && account.id) {
  662. seedSong.webdav_account_id = account.id.toString()
  663. }
  664. if (StrUtil.isEmpty(seedSong.id)) {
  665. seedSong.id = seedSong.remote_rel_path || seedSong.filePath
  666. }
  667. const saved = await this.mediaTable.saveOrUpdateWebDavItem(seedSong)
  668. if (saved) {
  669. savedCount += 1
  670. } else {
  671. Logger.warn(
  672. TAG,
  673. `[remote-debug] bootstrapRemoteDiscoverySongs save failed name=${this.getSongTitle(seedSong)}, ` +
  674. `type=${seedSong.type}, account=${seedSong.webdav_account_id ?? ''}, path=${this.sanitizeSongPath(seedSong.filePath)}`
  675. )
  676. }
  677. }
  678. if (savedCount <= 0) {
  679. return
  680. }
  681. const refreshedRemoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT))
  682. if (refreshedRemoteSongs.length === 0) {
  683. return
  684. }
  685. this.applyRemoteDiscoverySongs(refreshedRemoteSongs, true)
  686. this.scheduleVisibleRemoteCoverRepair()
  687. Logger.info(
  688. TAG,
  689. `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}, ` +
  690. `sample=${this.buildSongDebugLog(refreshedRemoteSongs)}`
  691. )
  692. } catch (error) {
  693. Logger.warn(TAG, `发现页远程预热失败: ${this.toErrorMessage(error)}`)
  694. } finally {
  695. this.isRemoteBootstrapLoading = false
  696. }
  697. }
  698. private queryTopPlayedSongs(limitCount: number): Promise<VideoItem[]> {
  699. return new Promise<VideoItem[]>((resolve) => {
  700. if (!this.mediaTable) {
  701. resolve([])
  702. return
  703. }
  704. this.mediaTable.queryByPlayCountDesc(limitCount, (result: VideoItem[]) => {
  705. resolve(result)
  706. })
  707. })
  708. }
  709. private queryFavoriteSongs(): Promise<VideoItem[]> {
  710. return new Promise<VideoItem[]>((resolve) => {
  711. if (!this.mediaTable) {
  712. resolve([])
  713. return
  714. }
  715. this.mediaTable.queryByisFav(1, (result: VideoItem[]) => {
  716. resolve(result)
  717. })
  718. })
  719. }
  720. private filterSongsWithCover(items: VideoItem[]): VideoItem[] {
  721. const result: VideoItem[] = []
  722. const seen: Set<string> = new Set<string>()
  723. for (let i = 0; i < items.length; i++) {
  724. const item = items[i]
  725. const filePath = item.filePath || ''
  726. if (StrUtil.isEmpty(filePath) || seen.has(filePath)) {
  727. continue
  728. }
  729. if (StrUtil.isEmpty(item.pixelMapPath)) {
  730. continue
  731. }
  732. seen.add(filePath)
  733. result.push(item)
  734. }
  735. return result
  736. }
  737. private filterUniqueSongs(items: VideoItem[]): VideoItem[] {
  738. const result: VideoItem[] = []
  739. const seen: Set<string> = new Set<string>()
  740. for (let i = 0; i < items.length; i++) {
  741. const item = items[i]
  742. const filePath = item.filePath || ''
  743. if (StrUtil.isEmpty(filePath) || seen.has(filePath)) {
  744. continue
  745. }
  746. seen.add(filePath)
  747. result.push(item)
  748. }
  749. return result
  750. }
  751. private pickRandomSongs(items: VideoItem[], count: number): VideoItem[] {
  752. const copy: VideoItem[] = items.slice()
  753. for (let i = copy.length - 1; i > 0; i--) {
  754. const randomIndex = Math.floor(Math.random() * (i + 1))
  755. const current = copy[i]
  756. copy[i] = copy[randomIndex]
  757. copy[randomIndex] = current
  758. }
  759. return copy.slice(0, Math.min(count, copy.length))
  760. }
  761. private pickPreferredSongs(items: VideoItem[], count: number): VideoItem[] {
  762. const songsWithCover = this.filterSongsWithCover(items)
  763. const result = this.pickRandomSongs(songsWithCover, count)
  764. if (result.length >= count) {
  765. return result
  766. }
  767. const seen: Set<string> = new Set<string>()
  768. for (let i = 0; i < result.length; i++) {
  769. seen.add(result[i].filePath)
  770. }
  771. const allSongs = this.pickRandomSongs(items, items.length)
  772. for (let i = 0; i < allSongs.length && result.length < count; i++) {
  773. const item = allSongs[i]
  774. if (seen.has(item.filePath)) {
  775. continue
  776. }
  777. seen.add(item.filePath)
  778. result.push(item)
  779. }
  780. return result
  781. }
  782. private isSameSongSelection(left: VideoItem[], right: VideoItem[]): boolean {
  783. if (left.length !== right.length) {
  784. return false
  785. }
  786. for (let i = 0; i < left.length; i++) {
  787. if (left[i].filePath !== right[i].filePath) {
  788. return false
  789. }
  790. }
  791. return true
  792. }
  793. private isSameLeadingSongs(left: VideoItem[], right: VideoItem[], count: number): boolean {
  794. const safeCount = Math.min(count, left.length, right.length)
  795. for (let i = 0; i < safeCount; i++) {
  796. if (left[i].filePath !== right[i].filePath) {
  797. return false
  798. }
  799. }
  800. return safeCount > 0
  801. }
  802. private isSameAlbumSelection(left: FindAlbumGroup[], right: FindAlbumGroup[]): boolean {
  803. if (left.length !== right.length) {
  804. return false
  805. }
  806. for (let i = 0; i < left.length; i++) {
  807. if (left[i].id !== right[i].id) {
  808. return false
  809. }
  810. }
  811. return true
  812. }
  813. private isSameLeadingAlbums(left: FindAlbumGroup[], right: FindAlbumGroup[], count: number): boolean {
  814. const safeCount = Math.min(count, left.length, right.length)
  815. for (let i = 0; i < safeCount; i++) {
  816. if (left[i].id !== right[i].id) {
  817. return false
  818. }
  819. }
  820. return safeCount > 0
  821. }
  822. private rotateSongs(items: VideoItem[], offset: number): VideoItem[] {
  823. if (items.length === 0) {
  824. return []
  825. }
  826. const safeOffset = ((offset % items.length) + items.length) % items.length
  827. if (safeOffset === 0) {
  828. return items.slice()
  829. }
  830. return items.slice(safeOffset).concat(items.slice(0, safeOffset))
  831. }
  832. private rotateAlbums(items: FindAlbumGroup[], offset: number): FindAlbumGroup[] {
  833. if (items.length === 0) {
  834. return []
  835. }
  836. const safeOffset = ((offset % items.length) + items.length) % items.length
  837. if (safeOffset === 0) {
  838. return items.slice()
  839. }
  840. return items.slice(safeOffset).concat(items.slice(0, safeOffset))
  841. }
  842. private ensureDifferentLeadingSongs(next: VideoItem[], current: VideoItem[]): VideoItem[] {
  843. const pageSize = this.getSectionPageSize(this.getDiscoverSectionColumns())
  844. if (!this.isSameLeadingSongs(next, current, pageSize)) {
  845. return next
  846. }
  847. if (next.length <= pageSize) {
  848. return next
  849. }
  850. const rotated = this.rotateSongs(next, pageSize)
  851. if (!this.isSameLeadingSongs(rotated, current, pageSize)) {
  852. return rotated
  853. }
  854. return next
  855. }
  856. private ensureDifferentLeadingAlbums(next: FindAlbumGroup[], current: FindAlbumGroup[]): FindAlbumGroup[] {
  857. const pageSize = this.getSectionPageSize(this.getDiscoverSectionColumns())
  858. if (!this.isSameLeadingAlbums(next, current, pageSize)) {
  859. return next
  860. }
  861. if (next.length <= pageSize) {
  862. return next
  863. }
  864. const rotated = this.rotateAlbums(next, pageSize)
  865. if (!this.isSameLeadingAlbums(rotated, current, pageSize)) {
  866. return rotated
  867. }
  868. return next
  869. }
  870. private pickDifferentPreferredSongs(items: VideoItem[], count: number, current: VideoItem[]): VideoItem[] {
  871. const next = this.pickPreferredSongs(items, count)
  872. if (!this.isSameSongSelection(next, current) || items.length <= current.length) {
  873. return next
  874. }
  875. const currentSet: Set<string> = new Set<string>()
  876. for (let i = 0; i < current.length; i++) {
  877. currentSet.add(current[i].filePath)
  878. }
  879. const differentItems: VideoItem[] = []
  880. for (let i = 0; i < items.length; i++) {
  881. const item = items[i]
  882. if (currentSet.has(item.filePath)) {
  883. continue
  884. }
  885. differentItems.push(item)
  886. }
  887. if (differentItems.length === 0) {
  888. return next
  889. }
  890. const refreshed = this.pickPreferredSongs(differentItems, count)
  891. if (refreshed.length >= Math.min(count, items.length)) {
  892. return refreshed
  893. }
  894. const result: VideoItem[] = refreshed.slice()
  895. const seen: Set<string> = new Set<string>()
  896. for (let i = 0; i < result.length; i++) {
  897. seen.add(result[i].filePath)
  898. }
  899. for (let i = 0; i < next.length && result.length < count; i++) {
  900. const item = next[i]
  901. if (seen.has(item.filePath)) {
  902. continue
  903. }
  904. seen.add(item.filePath)
  905. result.push(item)
  906. }
  907. return result
  908. }
  909. private pickDifferentAlbumGroups(items: FindAlbumGroup[], count: number, current: FindAlbumGroup[]): FindAlbumGroup[] {
  910. const next = this.pickAlbumGroups(items, count)
  911. if (!this.isSameAlbumSelection(next, current) || items.length <= current.length) {
  912. return next
  913. }
  914. const currentSet: Set<string> = new Set<string>()
  915. for (let i = 0; i < current.length; i++) {
  916. currentSet.add(current[i].id)
  917. }
  918. const differentItems: FindAlbumGroup[] = []
  919. for (let i = 0; i < items.length; i++) {
  920. const item = items[i]
  921. if (currentSet.has(item.id)) {
  922. continue
  923. }
  924. differentItems.push(item)
  925. }
  926. if (differentItems.length === 0) {
  927. return next
  928. }
  929. const refreshed = this.pickAlbumGroups(differentItems, count)
  930. if (refreshed.length >= Math.min(count, items.length)) {
  931. return refreshed
  932. }
  933. const result: FindAlbumGroup[] = refreshed.slice()
  934. const seen: Set<string> = new Set<string>()
  935. for (let i = 0; i < result.length; i++) {
  936. seen.add(result[i].id)
  937. }
  938. for (let i = 0; i < next.length && result.length < count; i++) {
  939. const item = next[i]
  940. if (seen.has(item.id)) {
  941. continue
  942. }
  943. seen.add(item.id)
  944. result.push(item)
  945. }
  946. return result
  947. }
  948. private shuffleAlbumGroups(items: FindAlbumGroup[]): FindAlbumGroup[] {
  949. const copy: FindAlbumGroup[] = items.slice()
  950. for (let i = copy.length - 1; i > 0; i--) {
  951. const randomIndex = Math.floor(Math.random() * (i + 1))
  952. const current = copy[i]
  953. copy[i] = copy[randomIndex]
  954. copy[randomIndex] = current
  955. }
  956. return copy
  957. }
  958. private pickAlbumGroups(items: FindAlbumGroup[], count: number): FindAlbumGroup[] {
  959. const preferred: FindAlbumGroup[] = []
  960. const fallback: FindAlbumGroup[] = []
  961. for (let i = 0; i < items.length; i++) {
  962. const item = items[i]
  963. if (item.songCount >= PREFERRED_ALBUM_MIN_COUNT) {
  964. preferred.push(item)
  965. } else {
  966. fallback.push(item)
  967. }
  968. }
  969. const result: FindAlbumGroup[] = []
  970. const shuffledPreferred = this.shuffleAlbumGroups(preferred)
  971. const shuffledFallback = this.shuffleAlbumGroups(fallback)
  972. for (let i = 0; i < shuffledPreferred.length && result.length < count; i++) {
  973. result.push(shuffledPreferred[i])
  974. }
  975. for (let i = 0; i < shuffledFallback.length && result.length < count; i++) {
  976. result.push(shuffledFallback[i])
  977. }
  978. return result
  979. }
  980. private buildAlbumGroups(items: VideoItem[], isRemote: boolean): FindAlbumGroup[] {
  981. const albumMap: Map<string, VideoItem[]> = new Map<string, VideoItem[]>()
  982. for (let i = 0; i < items.length; i++) {
  983. const item = items[i]
  984. const albumName = item.album?.trim() ?? ''
  985. if (albumName.length === 0) {
  986. continue
  987. }
  988. const artistName = item.artist?.trim() ?? ''
  989. const groupKey = `${isRemote ? item.type : CommonConstants.TYPE_LOCAL}_${albumName}_${artistName}`
  990. const groupSongs = albumMap.get(groupKey)
  991. if (groupSongs) {
  992. groupSongs.push(item)
  993. } else {
  994. albumMap.set(groupKey, [item])
  995. }
  996. }
  997. const result: FindAlbumGroup[] = []
  998. albumMap.forEach((songs: VideoItem[], key: string) => {
  999. const sortedSongs = this.sortAlbumSongs(songs)
  1000. const firstSong = sortedSongs[0]
  1001. const title = firstSong.album?.trim() ?? ''
  1002. if (title.length === 0) {
  1003. return
  1004. }
  1005. const coverSong = this.pickAlbumCoverSong(sortedSongs)
  1006. result.push({
  1007. id: key,
  1008. title: title,
  1009. artist: this.resolveAlbumArtist(sortedSongs),
  1010. coverPath: coverSong?.pixelMapPath ?? '',
  1011. songCount: sortedSongs.length,
  1012. songs: sortedSongs,
  1013. isRemote: isRemote,
  1014. sourceLabel: isRemote ? this.getCloudTypeLabel(firstSong) : '本地'
  1015. })
  1016. })
  1017. result.sort((left: FindAlbumGroup, right: FindAlbumGroup) => {
  1018. if (right.songCount !== left.songCount) {
  1019. return right.songCount - left.songCount
  1020. }
  1021. return left.title.localeCompare(right.title)
  1022. })
  1023. return result
  1024. }
  1025. private sortAlbumSongs(items: VideoItem[]): VideoItem[] {
  1026. return buildSortedDiscoverySongs(items, FindCollectionSortType.TRACK_ASC)
  1027. }
  1028. private pickAlbumCoverSong(items: VideoItem[]): VideoItem | undefined {
  1029. for (let i = 0; i < items.length; i++) {
  1030. if (StrUtil.isNotEmpty(items[i].pixelMapPath)) {
  1031. return items[i]
  1032. }
  1033. }
  1034. return items[0]
  1035. }
  1036. private resolveAlbumArtist(items: VideoItem[]): string {
  1037. for (let i = 0; i < items.length; i++) {
  1038. const artistName = items[i].artist?.trim() ?? ''
  1039. if (artistName.length > 0) {
  1040. return artistName
  1041. }
  1042. }
  1043. return '未知歌手'
  1044. }
  1045. private syncFindCanBackState(): void {
  1046. AppStorage.setOrCreate('findCanBack', this.isSearchMode || this.isAlbumMode)
  1047. }
  1048. private handleInnerBack(): boolean {
  1049. if (this.isSearchMode) {
  1050. this.exitSearchMode()
  1051. return true
  1052. }
  1053. if (this.isAlbumMode) {
  1054. this.exitAlbumMode()
  1055. return true
  1056. }
  1057. return false
  1058. }
  1059. private emitPlaylistPlay(playlistId: string, playlistName: string, songs: VideoItem[], startIndex: number,
  1060. playType?: number): void {
  1061. if (songs.length === 0) {
  1062. ToastUtil.showToast('暂无可播放歌曲')
  1063. return
  1064. }
  1065. const safeIndex = Math.min(Math.max(startIndex, 0), songs.length - 1)
  1066. Logger.info(
  1067. TAG,
  1068. `[remote-debug] emitPlaylistPlay id=${playlistId}, name=${playlistName}, count=${songs.length}, ` +
  1069. `startIndex=${safeIndex}, playType=${playType ?? -1}, sample=${this.buildSongDebugLog(songs)}`
  1070. )
  1071. if (playlistId === 'find-album-playlist' || playlistId.indexOf('find-') === 0) {
  1072. setFindPlaylist(playlistId, playlistName, songs, safeIndex)
  1073. }
  1074. const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
  1075. const playlistData = new PlaylistPlayRequest(
  1076. playlistId,
  1077. playlistName,
  1078. songs.length,
  1079. safeIndex,
  1080. songs.map((item: VideoItem): string => item.filePath),
  1081. false,
  1082. playType
  1083. )
  1084. savePendingPlaylistPlay(playlistData)
  1085. emitter.emit(eventPlaylistPlay, { data: playlistData })
  1086. }
  1087. private getCurrentPlaybackQueue(): VideoItem[] {
  1088. const queue = AppStorage.get('songList') as VideoItem[] | undefined
  1089. return Array.isArray(queue) ? queue : []
  1090. }
  1091. private getCurrentPlaybackIndex(): number {
  1092. const currentIndex = AppStorage.get('currIndex') as number | undefined
  1093. return typeof currentIndex === 'number' ? currentIndex : -1
  1094. }
  1095. private canFavoriteSong(item: VideoItem): boolean {
  1096. return StrUtil.isNotEmpty(item.filePath)
  1097. }
  1098. private canDeleteSong(item: VideoItem): boolean {
  1099. return item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath)
  1100. }
  1101. private canQueueSongForNextPlay(item: VideoItem): boolean {
  1102. return StrUtil.isNotEmpty(item.filePath)
  1103. }
  1104. private isSongFavorite(item: VideoItem): boolean {
  1105. return item.isFav === 1
  1106. }
  1107. private syncPlaybackQueueStores(previousQueue: VideoItem[], nextQueue: VideoItem[], currentIndex: number): void {
  1108. const findQueue = getFindVideoItems()
  1109. if (findQueue.length > 0 && this.isSameSongSelection(previousQueue, findQueue)) {
  1110. const playlistId = getFindPlaylistId().length > 0 ? getFindPlaylistId() : 'find-search'
  1111. const playlistName = getFindPlaylistName().length > 0 ? getFindPlaylistName() : '发现搜索'
  1112. setFindPlaylist(playlistId, playlistName, nextQueue, currentIndex)
  1113. }
  1114. const webdavQueue = getWebdavVideoItems()
  1115. if (webdavQueue.length > 0 && this.isSameSongSelection(previousQueue, webdavQueue)) {
  1116. setWebdavPlaylist(nextQueue, currentIndex)
  1117. }
  1118. const navidromeQueue = getNavidromeVideoItems()
  1119. if (navidromeQueue.length > 0 && this.isSameSongSelection(previousQueue, navidromeQueue)) {
  1120. setNavidromePlaylist(nextQueue, currentIndex)
  1121. }
  1122. }
  1123. private handleAddSongToNextPlay(song: VideoItem): void {
  1124. if (!this.canQueueSongForNextPlay(song)) {
  1125. ToastUtil.showToast('当前歌曲暂不支持加入下一首播放')
  1126. return
  1127. }
  1128. const currentQueue = this.getCurrentPlaybackQueue()
  1129. const result = insertSongToNextPlayQueue(
  1130. currentQueue,
  1131. this.currentSong?.filePath ?? '',
  1132. this.getCurrentPlaybackIndex(),
  1133. song
  1134. )
  1135. if (result.status === QueueInsertStatus.INVALID_SONG) {
  1136. ToastUtil.showToast('当前歌曲暂不支持加入下一首播放')
  1137. return
  1138. }
  1139. if (result.status === QueueInsertStatus.ALREADY_PLAYING) {
  1140. ToastUtil.showToast('当前正在播放这首歌')
  1141. return
  1142. }
  1143. if (result.status === QueueInsertStatus.START_PLAY) {
  1144. this.emitPlaylistPlay('find-search-next', '发现搜索', [song], 0)
  1145. ToastUtil.showToast('已开始播放')
  1146. return
  1147. }
  1148. AppStorage.setOrCreate('songList', result.queue)
  1149. AppStorage.setOrCreate('currIndex', result.currentIndex)
  1150. PreferencesUtil.putSync('LastMusicList', result.queue)
  1151. this.syncPlaybackQueueStores(currentQueue, result.queue, result.currentIndex)
  1152. ToastUtil.showToast('已添加到下一首播放')
  1153. }
  1154. @Builder
  1155. private SongContextMenuBuilder(item: VideoItem) {
  1156. Menu() {
  1157. MenuItem({
  1158. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')),
  1159. content: '下一首播放'
  1160. })
  1161. .visibility(this.canQueueSongForNextPlay(item) ? Visibility.Visible : Visibility.None)
  1162. .onClick(() => {
  1163. this.handleAddSongToNextPlay(item)
  1164. })
  1165. MenuItem({
  1166. symbolStartIcon: new SymbolGlyphModifier(this.isSongFavorite(item) ?
  1167. $r('sys.symbol.heart_fill') : $r('sys.symbol.heart')),
  1168. content: this.isSongFavorite(item) ? '取消收藏' : '收藏'
  1169. })
  1170. .visibility(this.canFavoriteSong(item) ? Visibility.Visible : Visibility.None)
  1171. .onClick(() => {
  1172. void this.handleAlbumFavoriteSong(item)
  1173. })
  1174. MenuItem({
  1175. symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')),
  1176. content: '删除'
  1177. })
  1178. .visibility(this.canDeleteSong(item) ? Visibility.Visible : Visibility.None)
  1179. .onClick(() => {
  1180. this.openDeleteSongDialog(item)
  1181. })
  1182. }
  1183. .attributeModifier(new MenuModifier())
  1184. }
  1185. private async getFullLocalSongsPool(): Promise<VideoItem[]> {
  1186. if (this.localSongsPool.length > 0) {
  1187. return this.localSongsPool
  1188. }
  1189. if (!this.mediaTable) {
  1190. return []
  1191. }
  1192. const localSongs = await this.mediaTable.queryAllVideos()
  1193. this.localSongsPool = this.filterUniqueSongs(localSongs)
  1194. return this.localSongsPool
  1195. }
  1196. private async getFullRemoteSongsPool(): Promise<VideoItem[]> {
  1197. if (this.remotePlaybackPool.length > 0) {
  1198. Logger.info(
  1199. TAG,
  1200. `[remote-debug] getFullRemoteSongsPool cacheHit=${this.remotePlaybackPool.length}, ` +
  1201. `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}`
  1202. )
  1203. return this.remotePlaybackPool
  1204. }
  1205. if (!this.mediaTable) {
  1206. return []
  1207. }
  1208. let remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync())
  1209. const fallbackSongs = remoteSongs.length > 0 ? remoteSongs : this.remoteSongsPool
  1210. const indexedSongs = await this.queryIndexedRemotePlaybackSongs(fallbackSongs)
  1211. if (indexedSongs.length === 0 && remoteSongs.length === 0) {
  1212. await this.ensureRemoteDiscoverySongsAvailable()
  1213. remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync())
  1214. }
  1215. Logger.info(
  1216. TAG,
  1217. `[remote-debug] getFullRemoteSongsPool beforeSelect indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` +
  1218. `indexedSample=${this.buildSongDebugLog(indexedSongs)}, dbSample=${this.buildSongDebugLog(remoteSongs)}`
  1219. )
  1220. this.remotePlaybackPool = buildPreferredRemotePlaybackPool(indexedSongs, remoteSongs)
  1221. Logger.info(
  1222. TAG,
  1223. `发现页全量云端播放池已就绪: indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` +
  1224. `selected=${this.remotePlaybackPool.length}, source=${indexedSongs.length > 0 ? 'indexed' : 'db'}, ` +
  1225. `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}`
  1226. )
  1227. return this.remotePlaybackPool
  1228. }
  1229. private async playFromFullRemoteQueue(playlistId: string, playlistName: string, fallbackSongs: VideoItem[],
  1230. targetSong?: VideoItem, randomStart: boolean = false): Promise<void> {
  1231. const fullQueue = await this.getFullRemoteSongsPool()
  1232. if (randomStart) {
  1233. const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs
  1234. if (queue.length === 0) {
  1235. ToastUtil.showToast('需要先配置网盘并加载歌曲后才能播放')
  1236. return
  1237. }
  1238. const startIndex = Math.floor(Math.random() * queue.length)
  1239. Logger.info(
  1240. TAG,
  1241. `[remote-debug] playFromFullRemoteQueue random playlist=${playlistId}, fullQueue=${fullQueue.length}, ` +
  1242. `fallback=${fallbackSongs.length}, selectedQueue=${queue.length}, source=${fullQueue.length > 0 ? 'full' : 'fallback'}, ` +
  1243. `startIndex=${startIndex}, startSong=${this.buildSongDebugItem(queue[startIndex])}`
  1244. )
  1245. this.emitPlaylistPlay(playlistId, playlistName, queue, startIndex, 3)
  1246. return
  1247. }
  1248. if (!targetSong) {
  1249. const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs
  1250. if (queue.length === 0) {
  1251. ToastUtil.showToast('暂无可播放歌曲')
  1252. return
  1253. }
  1254. this.emitPlaylistPlay(playlistId, playlistName, queue, 0)
  1255. return
  1256. }
  1257. const targetFilePath = targetSong.filePath || ''
  1258. Logger.info(
  1259. TAG,
  1260. `[remote-debug] playFromFullRemoteQueue precise playlist=${playlistId}, target=${this.buildSongDebugItem(targetSong)}, ` +
  1261. `fullQueue=${fullQueue.length}, fallback=${fallbackSongs.length}`
  1262. )
  1263. const fullIndex = resolveQueueStartIndex(fullQueue, targetFilePath)
  1264. if (fullIndex >= 0) {
  1265. this.emitPlaylistPlay(playlistId, playlistName, fullQueue, fullIndex)
  1266. return
  1267. }
  1268. const fallbackIndex = resolveQueueStartIndex(fallbackSongs, targetFilePath)
  1269. if (fallbackIndex >= 0) {
  1270. Logger.warn(TAG, `全量云端队列未命中当前歌曲,回退到当前卡片队列: ${targetFilePath}`)
  1271. this.emitPlaylistPlay(playlistId, playlistName, fallbackSongs, fallbackIndex)
  1272. return
  1273. }
  1274. const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs
  1275. if (queue.length === 0) {
  1276. ToastUtil.showToast('暂无可播放歌曲')
  1277. return
  1278. }
  1279. this.emitPlaylistPlay(playlistId, playlistName, queue, 0)
  1280. }
  1281. private async handleActionButtonTap(type: string): Promise<void> {
  1282. if (type === 'top') {
  1283. this.emitPlaylistPlay('find-top-played', '最近爱听', this.topPlayedSongsPool, 0)
  1284. return
  1285. }
  1286. if (type === 'local-random') {
  1287. const allLocalSongs = await this.getFullLocalSongsPool()
  1288. if (allLocalSongs.length === 0) {
  1289. ToastUtil.showToast('本地歌曲为空')
  1290. return
  1291. }
  1292. const startIndex = Math.floor(Math.random() * allLocalSongs.length)
  1293. this.emitPlaylistPlay('find-local-random-all', '随心所欲', allLocalSongs, startIndex, 3)
  1294. return
  1295. }
  1296. const allRemoteSongs = await this.getFullRemoteSongsPool()
  1297. Logger.info(
  1298. TAG,
  1299. `[remote-debug] handleActionButtonTap cloud-random remotePlaybackPool=${allRemoteSongs.length}, ` +
  1300. `remoteSongsPool=${this.remoteSongsPool.length}, remoteSongs=${this.remoteSongs.length}, cloudMoodSongs=${this.cloudMoodSongs.length}`
  1301. )
  1302. if (allRemoteSongs.length === 0) {
  1303. ToastUtil.showToast('需要先配置网盘并加载歌曲后才能播放')
  1304. return
  1305. }
  1306. await this.playFromFullRemoteQueue('find-random-cloud', '云端漫游', this.remoteSongsPool, undefined, true)
  1307. }
  1308. private handleSwiperTap(index: number): void {
  1309. this.emitPlaylistPlay('find-swiper', '发现推荐', this.swiperSongs, index)
  1310. }
  1311. private handleRemoteSongTap(index: number): void {
  1312. const targetSong = this.remoteSongs[index]
  1313. void this.playFromFullRemoteQueue('find-cloud', '漫步云端', this.remoteSongs, targetSong)
  1314. }
  1315. private handleHotSongTap(index: number): void {
  1316. this.emitPlaylistPlay('find-local-random', '本地随机', this.hotSongs, index)
  1317. }
  1318. private handleCloudMoodSongTap(index: number): void {
  1319. const targetSong = this.cloudMoodSongs[index]
  1320. void this.playFromFullRemoteQueue('find-cloud-mood', '云卷云舒', this.cloudMoodSongs, targetSong)
  1321. }
  1322. private handleRecentSongTap(index: number): void {
  1323. this.emitPlaylistPlay('find-recent', '最近播放', this.recentSongs, index)
  1324. }
  1325. private handlePopularSongTap(index: number): void {
  1326. this.emitPlaylistPlay('find-popular', '热门歌曲', this.popularSongs, index)
  1327. }
  1328. private handleFavoriteSongTap(index: number): void {
  1329. this.emitPlaylistPlay('find-favorite', '我的收藏', this.favoriteSongs, index)
  1330. }
  1331. private refreshLocalRandomSongs(): void {
  1332. this.hotSongs = this.pickDifferentPreferredSongs(this.localSongsPool, HOT_SECTION_COUNT, this.hotSongs)
  1333. }
  1334. private refreshCloudMoodSongs(): void {
  1335. this.cloudMoodSongs = this.pickDifferentPreferredSongs(this.remoteSongsPool, HOT_SECTION_COUNT, this.cloudMoodSongs)
  1336. }
  1337. private refreshCloudSongs(): void {
  1338. const current = this.remoteSongs.slice()
  1339. const next = this.pickDifferentPreferredSongs(this.remoteSongsPool, CLOUD_SECTION_COUNT, this.remoteSongs)
  1340. const updated = this.ensureDifferentLeadingSongs(next, this.remoteSongs)
  1341. Logger.info(
  1342. TAG,
  1343. `FindView refreshCloudSongs pool=${this.remoteSongsPool.length}, current=${this.buildSongSelectionLog(current)}, next=${this.buildSongSelectionLog(next)}, updated=${this.buildSongSelectionLog(updated)}`
  1344. )
  1345. this.remoteSongs = updated
  1346. this.cloudSectionPageIndex = 0
  1347. this.updateCloudSectionPages()
  1348. }
  1349. private refreshRecentSongs(): void {
  1350. this.recentSongs = this.recentSongsPool.slice(0, Math.min(RECENT_SECTION_COUNT, this.recentSongsPool.length))
  1351. this.recentSectionPageIndex = 0
  1352. }
  1353. private refreshPopularSongs(): void {
  1354. const current = this.popularSongs.slice()
  1355. const next = this.pickDifferentPreferredSongs(this.topPlayedSongsPool, POPULAR_SECTION_COUNT, this.popularSongs)
  1356. const updated = this.ensureDifferentLeadingSongs(next, this.popularSongs)
  1357. Logger.info(
  1358. TAG,
  1359. `FindView refreshPopularSongs pool=${this.topPlayedSongsPool.length}, current=${this.buildSongSelectionLog(current)}, next=${this.buildSongSelectionLog(next)}, updated=${this.buildSongSelectionLog(updated)}`
  1360. )
  1361. this.popularSongs = updated
  1362. this.popularSectionPageIndex = 0
  1363. this.updatePopularSectionPages()
  1364. }
  1365. private refreshFeaturedAlbums(): void {
  1366. const current = this.featuredAlbums.slice()
  1367. const next = this.pickDifferentAlbumGroups(this.featuredAlbumsPool, FEATURED_ALBUM_COUNT, this.featuredAlbums)
  1368. const updated = this.ensureDifferentLeadingAlbums(next, this.featuredAlbums)
  1369. Logger.info(
  1370. TAG,
  1371. `FindView refreshFeaturedAlbums pool=${this.featuredAlbumsPool.length}, current=${this.buildAlbumSelectionLog(current)}, next=${this.buildAlbumSelectionLog(next)}, updated=${this.buildAlbumSelectionLog(updated)}`
  1372. )
  1373. this.featuredAlbums = updated
  1374. this.featuredAlbumPageIndex = 0
  1375. this.updateFeaturedAlbumPages()
  1376. }
  1377. private refreshCloudAlbums(): void {
  1378. const current = this.cloudAlbums.slice()
  1379. const next = this.pickDifferentAlbumGroups(this.cloudAlbumsPool, CLOUD_ALBUM_COUNT, this.cloudAlbums)
  1380. const updated = this.ensureDifferentLeadingAlbums(next, this.cloudAlbums)
  1381. Logger.info(
  1382. TAG,
  1383. `FindView refreshCloudAlbums pool=${this.cloudAlbumsPool.length}, current=${this.buildAlbumSelectionLog(current)}, next=${this.buildAlbumSelectionLog(next)}, updated=${this.buildAlbumSelectionLog(updated)}`
  1384. )
  1385. this.cloudAlbums = updated
  1386. this.cloudAlbumPageIndex = 0
  1387. this.updateCloudAlbumPages()
  1388. }
  1389. private openLocalSpecialList(target: string): void {
  1390. if (target === 'recent') {
  1391. this.currentAlbumDefaultSortType = FindCollectionSortType.RECENT_DESC
  1392. this.currentAlbumSupportRecentPlayedSort = true
  1393. this.openSongCollectionDetail('find-recent-collection', '最近播放', '', this.recentSongsPool)
  1394. return
  1395. }
  1396. if (target === 'favorite') {
  1397. this.currentAlbumDefaultSortType = FindCollectionSortType.NAME_ASC
  1398. this.currentAlbumSupportRecentPlayedSort = false
  1399. this.openSongCollectionDetail('find-favorite-collection', '我的收藏', '', this.favoriteSongsPool)
  1400. return
  1401. }
  1402. }
  1403. private openSongCollectionDetail(id: string, title: string, sourceLabel: string, songs: VideoItem[]): void {
  1404. if (songs.length === 0) {
  1405. ToastUtil.showToast('暂无可展示歌曲')
  1406. return
  1407. }
  1408. const coverSong = this.pickAlbumCoverSong(songs)
  1409. this.currentAlbumId = id
  1410. this.currentAlbumTitle = title
  1411. this.currentAlbumArtist = ''
  1412. this.currentAlbumCoverPath = coverSong?.pixelMapPath ?? ''
  1413. this.currentAlbumSourceLabel = sourceLabel
  1414. this.currentAlbumSongs = songs.slice()
  1415. this.isSearchMode = false
  1416. this.getUIContext()?.animateTo({ duration: 500 }, () => {
  1417. this.isAlbumMode = true
  1418. })
  1419. }
  1420. private openAlbumDetail(album: FindAlbumGroup): void {
  1421. this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC
  1422. this.currentAlbumSupportRecentPlayedSort = false
  1423. this.currentAlbumId = album.id
  1424. this.currentAlbumTitle = album.title
  1425. this.currentAlbumArtist = album.artist
  1426. this.currentAlbumCoverPath = album.coverPath
  1427. this.currentAlbumSourceLabel = album.isRemote ? '云端专辑' : '精选专辑'
  1428. this.currentAlbumSongs = album.songs.slice()
  1429. this.isAlbumMode = true
  1430. this.isSearchMode = false
  1431. }
  1432. private getPlaylistCover(playlist: Playlist): string | Resource {
  1433. if (StrUtil.isNotEmpty(playlist.coverPath)) {
  1434. return playlist.coverPath as string
  1435. }
  1436. const cachedSongs = this.playlistSongsCache.get(playlist.id)
  1437. const coverSong = cachedSongs ? this.pickAlbumCoverSong(cachedSongs) : undefined
  1438. if (coverSong && StrUtil.isNotEmpty(coverSong.pixelMapPath)) {
  1439. return coverSong.pixelMapPath as string
  1440. }
  1441. return $r('app.media.alt')
  1442. }
  1443. private getPlaylistSubtitle(playlist: Playlist): string {
  1444. if (StrUtil.isNotEmpty(playlist.description)) {
  1445. return playlist.description as string
  1446. }
  1447. return `${playlist.songCount} 首歌曲`
  1448. }
  1449. private presentPlaylistDetail(playlist: Playlist, songs: VideoItem[]): void {
  1450. if (songs.length === 0) {
  1451. ToastUtil.showToast('歌单里还没有可展示歌曲')
  1452. return
  1453. }
  1454. this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC
  1455. this.currentAlbumSupportRecentPlayedSort = false
  1456. const coverSong = this.pickAlbumCoverSong(songs)
  1457. this.currentAlbumId = `find-playlist-${playlist.id}`
  1458. this.currentAlbumTitle = playlist.name
  1459. this.currentAlbumArtist = '心动歌单'
  1460. this.currentAlbumCoverPath = StrUtil.isNotEmpty(playlist.coverPath) ? playlist.coverPath as string :
  1461. (coverSong?.pixelMapPath ?? '')
  1462. this.currentAlbumSourceLabel = '心动歌单'
  1463. this.currentAlbumSongs = songs.slice()
  1464. this.isAlbumMode = true
  1465. this.isSearchMode = false
  1466. }
  1467. private async openPlaylistDetail(playlist: Playlist): Promise<void> {
  1468. if (!this.playlistTable) {
  1469. ToastUtil.showToast('歌单加载失败')
  1470. return
  1471. }
  1472. if (playlist.songCount <= 0) {
  1473. ToastUtil.showToast('歌单里还没有歌曲')
  1474. return
  1475. }
  1476. const cachedSongs = this.playlistSongsCache.get(playlist.id)
  1477. if (cachedSongs && cachedSongs.length > 0) {
  1478. this.presentPlaylistDetail(playlist, cachedSongs)
  1479. return
  1480. }
  1481. try {
  1482. const playlistSongs = await this.playlistTable.queryPlaylistSongs(playlist.id)
  1483. if (playlistSongs.length === 0) {
  1484. ToastUtil.showToast('歌单里还没有歌曲')
  1485. return
  1486. }
  1487. const songs = await convertPlaylistSongsToVideoItems(getContext(this) as common.Context, playlistSongs)
  1488. if (songs.length === 0) {
  1489. ToastUtil.showToast('歌单里还没有可播放歌曲')
  1490. return
  1491. }
  1492. this.playlistSongsCache.set(playlist.id, songs)
  1493. this.presentPlaylistDetail(playlist, songs)
  1494. } catch (error) {
  1495. Logger.error(TAG, `发现页打开歌单失败: ${this.toErrorMessage(error as Object)}`)
  1496. ToastUtil.showToast('歌单加载失败')
  1497. }
  1498. }
  1499. private async resolveHeartPlaylistCovers(ticket: number, playlists: Playlist[]): Promise<void> {
  1500. if (!this.playlistTable || playlists.length === 0) {
  1501. return
  1502. }
  1503. const context = getContext(this) as common.Context
  1504. for (let index = 0; index < playlists.length; index += 1) {
  1505. if (ticket !== this.heartPlaylistCoverTicket) {
  1506. return
  1507. }
  1508. const playlist = playlists[index]
  1509. if (StrUtil.isNotEmpty(playlist.coverPath) || playlist.songCount <= 0) {
  1510. continue
  1511. }
  1512. try {
  1513. let songs = this.playlistSongsCache.get(playlist.id)
  1514. if (!songs || songs.length === 0) {
  1515. const playlistSongs = await this.playlistTable.queryPlaylistSongs(playlist.id)
  1516. if (playlistSongs.length === 0) {
  1517. continue
  1518. }
  1519. songs = await convertPlaylistSongsToVideoItems(context, playlistSongs)
  1520. if (songs.length > 0) {
  1521. this.playlistSongsCache.set(playlist.id, songs)
  1522. }
  1523. }
  1524. if (!songs || songs.length === 0) {
  1525. continue
  1526. }
  1527. const coverSong = this.pickAlbumCoverSong(songs)
  1528. if (!coverSong || StrUtil.isEmpty(coverSong.pixelMapPath)) {
  1529. continue
  1530. }
  1531. playlist.coverPath = coverSong.pixelMapPath as string
  1532. this.heartPlaylists = this.heartPlaylists.slice()
  1533. } catch (error) {
  1534. Logger.warn(TAG, `发现页补全歌单封面失败: ${playlist.name}, ${this.toErrorMessage(error as Object)}`)
  1535. }
  1536. }
  1537. }
  1538. private exitAlbumMode(): void {
  1539. this.getUIContext()?.animateTo({ duration: 500 }, () => {
  1540. this.isAlbumMode = false
  1541. })
  1542. this.currentAlbumId = ''
  1543. this.currentAlbumTitle = ''
  1544. this.currentAlbumArtist = ''
  1545. this.currentAlbumCoverPath = ''
  1546. this.currentAlbumSourceLabel = ''
  1547. this.currentAlbumSongs = []
  1548. this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC
  1549. this.currentAlbumSupportRecentPlayedSort = false
  1550. }
  1551. private canDeleteCurrentAlbumSongs(): boolean {
  1552. if (this.currentAlbumSongs.length <= 0) {
  1553. return false
  1554. }
  1555. return this.currentAlbumSongs.every((item: VideoItem) =>
  1556. item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath))
  1557. }
  1558. private canFavoriteCurrentAlbumSong(song: VideoItem): boolean {
  1559. return StrUtil.isNotEmpty(song.filePath)
  1560. }
  1561. private updateAlbumSongFavoriteState(filePath: string, isFav: number): void {
  1562. this.currentAlbumSongs.forEach((item: VideoItem) => {
  1563. if (item.filePath === filePath) {
  1564. item.isFav = isFav
  1565. }
  1566. })
  1567. }
  1568. private async handleAlbumFavoriteSong(song: VideoItem): Promise<void> {
  1569. if (!this.mediaTable) {
  1570. ToastUtil.showToast('收藏功能暂不可用')
  1571. return
  1572. }
  1573. if (!this.canFavoriteCurrentAlbumSong(song)) {
  1574. ToastUtil.showToast('当前歌曲暂不支持收藏')
  1575. return
  1576. }
  1577. const nextFav: number = song.isFav === 1 ? 0 : 1
  1578. this.mediaTable.updateIsFavByFilePath(song.filePath, nextFav, async (result: boolean, error?: string) => {
  1579. if (!result) {
  1580. Logger.warn(TAG, `发现页专辑详情收藏更新失败: ${song.filePath}, error=${error ?? ''}`)
  1581. ToastUtil.showToast(nextFav === 1 ? '收藏失败' : '取消收藏失败')
  1582. return
  1583. }
  1584. song.isFav = nextFav
  1585. this.updateAlbumSongFavoriteState(song.filePath, nextFav)
  1586. await this.loadDiscoveryContent(false)
  1587. this.syncCurrentAlbumDetailFromState()
  1588. ToastUtil.showToast(nextFav === 1 ? '收藏成功' : '取消收藏成功')
  1589. })
  1590. }
  1591. private closeDeleteDialog(afterClose?: () => void): void {
  1592. if (this.deleteComponentId > 0) {
  1593. try {
  1594. this.getUIContext().getPromptAction().closeCustomDialog(this.deleteComponentId)
  1595. } catch (_error) {
  1596. }
  1597. this.deleteComponentId = 0
  1598. }
  1599. if (afterClose) {
  1600. setTimeout(() => {
  1601. afterClose()
  1602. }, 16)
  1603. }
  1604. }
  1605. private filterPlaylistCachesByDeletedKeys(deletedKeys: Set<string>): void {
  1606. this.playlistSongsCache.forEach((songs: VideoItem[], key: string) => {
  1607. this.playlistSongsCache.set(key, songs.filter((item: VideoItem) => !deletedKeys.has(item.filePath)))
  1608. })
  1609. }
  1610. private syncCurrentAlbumDetailFromState(): void {
  1611. if (StrUtil.isEmpty(this.currentAlbumId)) {
  1612. return
  1613. }
  1614. if (this.currentAlbumId === 'find-recent-collection') {
  1615. this.currentAlbumSongs = this.recentSongsPool.slice()
  1616. this.currentAlbumCoverPath = this.pickAlbumCoverSong(this.currentAlbumSongs)?.pixelMapPath ?? ''
  1617. } else if (this.currentAlbumId === 'find-favorite-collection') {
  1618. this.currentAlbumSongs = this.favoriteSongsPool.slice()
  1619. this.currentAlbumCoverPath = this.pickAlbumCoverSong(this.currentAlbumSongs)?.pixelMapPath ?? ''
  1620. } else if (this.currentAlbumId.startsWith('find-playlist-')) {
  1621. const playlistId = this.currentAlbumId.replace('find-playlist-', '')
  1622. const playlist = this.heartPlaylists.find((item: Playlist) => item.id === playlistId)
  1623. const songs = this.playlistSongsCache.get(playlistId) ?? []
  1624. this.currentAlbumSongs = songs.slice()
  1625. if (playlist) {
  1626. this.currentAlbumTitle = playlist.name
  1627. this.currentAlbumArtist = '心动歌单'
  1628. const coverSong = this.pickAlbumCoverSong(songs)
  1629. this.currentAlbumCoverPath = StrUtil.isNotEmpty(playlist.coverPath) ? playlist.coverPath as string :
  1630. (coverSong?.pixelMapPath ?? '')
  1631. } else {
  1632. this.currentAlbumCoverPath = ''
  1633. }
  1634. } else {
  1635. const album = this.featuredAlbumsPool.find((item: FindAlbumGroup) => item.id === this.currentAlbumId) ??
  1636. this.cloudAlbumsPool.find((item: FindAlbumGroup) => item.id === this.currentAlbumId)
  1637. if (album) {
  1638. this.currentAlbumTitle = album.title
  1639. this.currentAlbumArtist = album.artist
  1640. this.currentAlbumCoverPath = album.coverPath
  1641. this.currentAlbumSourceLabel = album.isRemote ? '云端专辑' : '精选专辑'
  1642. this.currentAlbumSongs = album.songs.slice()
  1643. } else {
  1644. this.currentAlbumCoverPath = ''
  1645. this.currentAlbumSongs = []
  1646. }
  1647. }
  1648. }
  1649. private async handleAlbumDeleteSuccess(deletedItems: VideoItem[]): Promise<void> {
  1650. if (deletedItems.length <= 0) {
  1651. return
  1652. }
  1653. const deletedKeys: Set<string> = new Set<string>()
  1654. deletedItems.forEach((item: VideoItem) => {
  1655. if (StrUtil.isNotEmpty(item.filePath)) {
  1656. deletedKeys.add(item.filePath)
  1657. }
  1658. })
  1659. if (deletedKeys.size <= 0) {
  1660. return
  1661. }
  1662. this.currentAlbumSongs = this.currentAlbumSongs.filter((item: VideoItem) => !deletedKeys.has(item.filePath))
  1663. this.searchResults = this.searchResults.filter((item: VideoItem) => !deletedKeys.has(item.filePath))
  1664. this.filterPlaylistCachesByDeletedKeys(deletedKeys)
  1665. await this.loadDiscoveryContent(false)
  1666. this.syncCurrentAlbumDetailFromState()
  1667. if (this.isSearchMode && this.searchText.length > 0) {
  1668. await this.onSearchInput(this.searchText)
  1669. }
  1670. ToastUtil.showToast('删除成功')
  1671. }
  1672. private openDeleteSongDialog(song: VideoItem): void {
  1673. if (song.type !== CommonConstants.TYPE_LOCAL || StrUtil.isEmpty(song.filePath)) {
  1674. ToastUtil.showToast('云端歌曲暂不支持删除')
  1675. return
  1676. }
  1677. this.pendingDeleteSongs = [song]
  1678. this.getUIContext().getPromptAction().openCustomDialog({
  1679. builder: () => {
  1680. this.buildDeleteSongDialog()
  1681. },
  1682. isModal: true,
  1683. showInSubWindow: false,
  1684. maskColor: Color.Transparent,
  1685. dialogTransition: TransitionEffect.translate({ x: 0, y: 120, z: 0 })
  1686. .combine(TransitionEffect.opacity(0.01))
  1687. .animation({ duration: 260, curve: Curve.EaseOut }),
  1688. maskTransition: TransitionEffect.opacity(0)
  1689. .animation({ duration: 220, curve: Curve.EaseOut })
  1690. }).then((dialogId: number) => {
  1691. this.deleteComponentId = dialogId
  1692. }).catch((error: BusinessError) => {
  1693. Logger.error(TAG, `发现页打开删除弹窗失败: ${error.message}`)
  1694. })
  1695. }
  1696. @Builder
  1697. private buildDeleteSongDialog() {
  1698. DeleteComptent({
  1699. selectedFiles: this.pendingDeleteSongs,
  1700. onCancel: () => {
  1701. this.closeDeleteDialog(() => {
  1702. this.pendingDeleteSongs = []
  1703. })
  1704. },
  1705. onDeleteResult: (result: boolean) => {
  1706. const deletedItems = this.pendingDeleteSongs.slice()
  1707. this.closeDeleteDialog(() => {
  1708. this.pendingDeleteSongs = []
  1709. if (result) {
  1710. void this.handleAlbumDeleteSuccess(deletedItems)
  1711. } else {
  1712. ToastUtil.showToast('删除失败')
  1713. }
  1714. })
  1715. }
  1716. })
  1717. }
  1718. private playCurrentAlbum(startIndex: number, songs: VideoItem[] = this.currentAlbumSongs): void {
  1719. if (songs.length === 0) {
  1720. ToastUtil.showToast('专辑里还没有歌曲')
  1721. return
  1722. }
  1723. this.emitPlaylistPlay(
  1724. 'find-album-playlist',
  1725. this.currentAlbumTitle.length > 0 ? this.currentAlbumTitle : '专辑',
  1726. songs,
  1727. startIndex
  1728. )
  1729. }
  1730. private handleAlbumPlayAll(songs?: VideoItem[]): void {
  1731. const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs
  1732. this.playCurrentAlbum(0, targetSongs)
  1733. }
  1734. private handleAlbumRandomPlay(songs?: VideoItem[]): void {
  1735. const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs
  1736. if (targetSongs.length === 0) {
  1737. ToastUtil.showToast('专辑里还没有歌曲')
  1738. return
  1739. }
  1740. const startIndex = Math.floor(Math.random() * targetSongs.length)
  1741. this.playCurrentAlbum(startIndex, targetSongs)
  1742. }
  1743. private handleAlbumSongTap(index: number, songs?: VideoItem[]): void {
  1744. const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs
  1745. this.playCurrentAlbum(index, targetSongs)
  1746. }
  1747. private loadSearchHistory(): void {
  1748. this.searchHistoryItems = SearchHistoryUtil.load(this.searchHistoryScope)
  1749. }
  1750. private saveSearchHistory(keyword: string): void {
  1751. this.searchHistoryItems = SearchHistoryUtil.save(this.searchHistoryScope, keyword)
  1752. }
  1753. private commitSearchHistory(keyword: string): void {
  1754. const normalized = keyword.trim()
  1755. if (normalized.length === 0) {
  1756. return
  1757. }
  1758. this.saveSearchHistory(normalized)
  1759. }
  1760. private applySearchHistory(keyword: string): void {
  1761. this.searchText = keyword
  1762. this.searchResults = []
  1763. if (!this.isSearchMode) {
  1764. this.isSearchMode = true
  1765. }
  1766. this.isSearchLoading = true
  1767. this.searchController.stopEditing()
  1768. setTimeout(() => {
  1769. void this.onSearchInput(keyword)
  1770. }, 16)
  1771. }
  1772. private exitSearchMode(): void {
  1773. this.getUIContext()?.animateTo({ duration: 555 }, () => {
  1774. this.isSearchMode = false
  1775. })
  1776. this.searchText = ''
  1777. this.searchResults = []
  1778. this.isSearchLoading = false
  1779. this.searchController.stopEditing()
  1780. }
  1781. private async ensureSearchSourceReady(): Promise<void> {
  1782. if (!this.mediaTable) {
  1783. return
  1784. }
  1785. if (this.localSongsPool.length === 0) {
  1786. const localSongs = await this.mediaTable.queryAllVideos()
  1787. this.localSongsPool = this.filterUniqueSongs(localSongs)
  1788. }
  1789. if (this.searchRemoteSongsPool.length === 0) {
  1790. const remoteSongs = await this.mediaTable.queryRemoteSongsAsync()
  1791. this.searchRemoteSongsPool = await this.resolveRemoteSongCovers(this.filterUniqueSongs(remoteSongs), true)
  1792. }
  1793. }
  1794. private matchesSearchKeyword(item: VideoItem, keyword: string): boolean {
  1795. const normalizedKeyword = keyword.toLowerCase()
  1796. const fields: string[] = [
  1797. this.getSongTitle(item),
  1798. this.getSongSubtitle(item),
  1799. StrUtil.isNotEmpty(item.artist) ? item.artist as string : '',
  1800. StrUtil.isNotEmpty(item.album) ? item.album as string : '',
  1801. StrUtil.isNotEmpty(item.fileName) ? item.fileName as string : ''
  1802. ]
  1803. for (let i = 0; i < fields.length; i++) {
  1804. const field = fields[i]
  1805. if (field.toLowerCase().indexOf(normalizedKeyword) >= 0) {
  1806. return true
  1807. }
  1808. }
  1809. return false
  1810. }
  1811. private async onSearchInput(value: string): Promise<void> {
  1812. const currentTicket = ++this.searchTicket
  1813. this.searchText = value.trim()
  1814. if (this.searchText.length === 0) {
  1815. this.isSearchLoading = false
  1816. this.searchResults = []
  1817. this.loadSearchHistory()
  1818. return
  1819. }
  1820. if (!this.isSearchMode) {
  1821. this.isSearchMode = true
  1822. }
  1823. this.isSearchLoading = true
  1824. await new Promise<void>((resolve: () => void) => {
  1825. setTimeout(() => {
  1826. resolve()
  1827. }, 16)
  1828. })
  1829. await this.ensureSearchSourceReady()
  1830. if (currentTicket !== this.searchTicket) {
  1831. return
  1832. }
  1833. const result: VideoItem[] = []
  1834. for (let i = 0; i < this.localSongsPool.length; i++) {
  1835. const item = this.localSongsPool[i]
  1836. if (this.matchesSearchKeyword(item, this.searchText)) {
  1837. result.push(item)
  1838. }
  1839. }
  1840. for (let i = 0; i < this.searchRemoteSongsPool.length; i++) {
  1841. const item = this.searchRemoteSongsPool[i]
  1842. if (this.matchesSearchKeyword(item, this.searchText)) {
  1843. result.push(item)
  1844. }
  1845. }
  1846. this.searchResults = this.filterUniqueSongs(result)
  1847. this.isSearchLoading = false
  1848. }
  1849. private getSwiperAspectRatio(): number {
  1850. return this.currentBreakpoint === BreakpointTypeEnum.SM ? 1.06 : 1.66
  1851. }
  1852. private getSwiperTextSize(smallSize: number, largeSize: number): number {
  1853. return this.currentBreakpoint === BreakpointTypeEnum.SM ? smallSize : largeSize
  1854. }
  1855. private getIndicatorItemWidth(): number {
  1856. const widthVp = this.windowWidth > 0 ? px2vp(this.windowWidth) : 360
  1857. const count = Math.max(1, this.swiperSongs.length)
  1858. const gap = 8
  1859. const horizontalPadding = this.currentBreakpoint === BreakpointTypeEnum.SM ? 64 : 120
  1860. const usableWidth = Math.max(80, widthVp - horizontalPadding - gap * (count - 1))
  1861. return usableWidth / count
  1862. }
  1863. private resetPagedSectionIndices(): void {
  1864. this.cloudSectionPageIndex = 0
  1865. this.featuredAlbumPageIndex = 0
  1866. this.cloudAlbumPageIndex = 0
  1867. this.recentSectionPageIndex = 0
  1868. this.popularSectionPageIndex = 0
  1869. this.favoriteSectionPageIndex = 0
  1870. }
  1871. private getDiscoverSectionColumns(): number {
  1872. return this.currentBreakpoint === BreakpointTypeEnum.SM ? 3 : 4
  1873. }
  1874. private getSectionPageSize(columns: number): number {
  1875. return columns * 2
  1876. }
  1877. private getSafeSectionPageIndex(pageCount: number, currentIndex: number): number {
  1878. if (pageCount <= 0) {
  1879. return 0
  1880. }
  1881. return Math.min(Math.max(currentIndex, 0), pageCount - 1)
  1882. }
  1883. private buildSongSelectionLog(items: VideoItem[], limit: number = 4): string {
  1884. if (items.length === 0) {
  1885. return '[]'
  1886. }
  1887. const parts: string[] = []
  1888. const maxCount = Math.min(limit, items.length)
  1889. for (let i = 0; i < maxCount; i++) {
  1890. parts.push(this.getSongTitle(items[i]))
  1891. }
  1892. return `[${parts.join(', ')}](${items.length})`
  1893. }
  1894. private buildSongDebugLog(items: VideoItem[], limit: number = 5): string {
  1895. if (items.length === 0) {
  1896. return '[]'
  1897. }
  1898. const parts: string[] = []
  1899. const maxCount = Math.min(limit, items.length)
  1900. for (let i = 0; i < maxCount; i++) {
  1901. parts.push(this.buildSongDebugItem(items[i]))
  1902. }
  1903. return `[${parts.join('; ')}](${items.length})`
  1904. }
  1905. private buildSongDebugItem(item?: VideoItem): string {
  1906. if (!item) {
  1907. return 'unknown'
  1908. }
  1909. return `${this.getSongTitle(item)}|type=${item.type}|acc=${item.webdav_account_id ?? ''}|path=${this.sanitizeSongPath(item.filePath)}|` +
  1910. `cover=${this.sanitizeCoverValue(item.pixelMapPath)}`
  1911. }
  1912. private sanitizeSongPath(path?: string): string {
  1913. if (StrUtil.isEmpty(path)) {
  1914. return ''
  1915. }
  1916. const value = path as string
  1917. if (value.length <= 48) {
  1918. return value
  1919. }
  1920. return `...${value.substring(value.length - 48)}`
  1921. }
  1922. private sanitizeCoverValue(value?: string): string {
  1923. if (StrUtil.isEmpty(value)) {
  1924. return ''
  1925. }
  1926. const coverValue = value as string
  1927. if (coverValue.length <= 72) {
  1928. return coverValue
  1929. }
  1930. return `...${coverValue.substring(coverValue.length - 72)}`
  1931. }
  1932. private logRemoteDiscoveryCoverState(label: string, items: VideoItem[]): void {
  1933. if (!items || items.length === 0) {
  1934. Logger.info(TAG, `[cover-debug] ${label} items=0`)
  1935. return
  1936. }
  1937. let coverCount = 0
  1938. let absoluteCoverCount = 0
  1939. let relativeCoverCount = 0
  1940. for (let i = 0; i < items.length; i++) {
  1941. const coverPath = items[i].pixelMapPath ?? ''
  1942. if (coverPath.length <= 0) {
  1943. continue
  1944. }
  1945. coverCount += 1
  1946. if (coverPath.startsWith('http://') || coverPath.startsWith('https://') || coverPath.startsWith('file://')) {
  1947. absoluteCoverCount += 1
  1948. } else {
  1949. relativeCoverCount += 1
  1950. }
  1951. }
  1952. Logger.info(
  1953. TAG,
  1954. `[cover-debug] ${label} items=${items.length}, coverCount=${coverCount}, absoluteCover=${absoluteCoverCount}, ` +
  1955. `relativeCover=${relativeCoverCount}, sample=${this.buildSongDebugLog(items)}`
  1956. )
  1957. }
  1958. private buildAlbumSelectionLog(items: FindAlbumGroup[], limit: number = 4): string {
  1959. if (items.length === 0) {
  1960. return '[]'
  1961. }
  1962. const parts: string[] = []
  1963. const maxCount = Math.min(limit, items.length)
  1964. for (let i = 0; i < maxCount; i++) {
  1965. parts.push(items[i].title)
  1966. }
  1967. return `[${parts.join(', ')}](${items.length})`
  1968. }
  1969. private buildSongPages(prefix: string, items: VideoItem[], columns: number): FindSongPage[] {
  1970. const pages: FindSongPage[] = []
  1971. const pageSize = this.getSectionPageSize(columns)
  1972. for (let start = 0, pageIndex = 0; start < items.length; start += pageSize, pageIndex++) {
  1973. pages.push({
  1974. id: prefix + '_' + pageIndex,
  1975. items: items.slice(start, start + pageSize)
  1976. })
  1977. }
  1978. return pages
  1979. }
  1980. private buildAlbumPages(prefix: string, items: FindAlbumGroup[], columns: number): FindAlbumPage[] {
  1981. const pages: FindAlbumPage[] = []
  1982. const pageSize = this.getSectionPageSize(columns)
  1983. for (let start = 0, pageIndex = 0; start < items.length; start += pageSize, pageIndex++) {
  1984. pages.push({
  1985. id: prefix + '_' + pageIndex,
  1986. items: items.slice(start, start + pageSize)
  1987. })
  1988. }
  1989. return pages
  1990. }
  1991. private rebuildPagedSectionSources(): void {
  1992. this.updateCloudSectionPages()
  1993. this.updateFeaturedAlbumPages()
  1994. this.updateCloudAlbumPages()
  1995. this.updatePopularSectionPages()
  1996. Logger.info(
  1997. TAG,
  1998. `FindView rebuildPagedSectionSources columns=${this.getDiscoverSectionColumns()}, cloudPages=${this.cloudSectionPages.length}, featuredAlbumPages=${this.featuredAlbumPages.length}, cloudAlbumPages=${this.cloudAlbumPages.length}, popularPages=${this.popularSectionPages.length}`
  1999. )
  2000. }
  2001. private updateCloudSectionPages(): void {
  2002. this.cloudSectionPageVersion++
  2003. this.cloudSectionPages =
  2004. this.buildSongPages(`find_cloud_section_${this.cloudSectionPageVersion}`, this.remoteSongs, this.getDiscoverSectionColumns())
  2005. this.cloudSectionPageIndex = this.getSafeSectionPageIndex(this.cloudSectionPages.length, this.cloudSectionPageIndex)
  2006. Logger.info(
  2007. TAG,
  2008. `FindView updateCloudSectionPages version=${this.cloudSectionPageVersion}, pageIndex=${this.cloudSectionPageIndex}, pages=${this.cloudSectionPages.length}, songs=${this.buildSongSelectionLog(this.remoteSongs)}`
  2009. )
  2010. }
  2011. private updateFeaturedAlbumPages(): void {
  2012. this.featuredAlbumPageVersion++
  2013. this.featuredAlbumPages =
  2014. this.buildAlbumPages(`find_featured_album_section_${this.featuredAlbumPageVersion}`, this.featuredAlbums,
  2015. this.getDiscoverSectionColumns())
  2016. this.featuredAlbumPageIndex = this.getSafeSectionPageIndex(this.featuredAlbumPages.length, this.featuredAlbumPageIndex)
  2017. Logger.info(
  2018. TAG,
  2019. `FindView updateFeaturedAlbumPages version=${this.featuredAlbumPageVersion}, pageIndex=${this.featuredAlbumPageIndex}, pages=${this.featuredAlbumPages.length}, albums=${this.buildAlbumSelectionLog(this.featuredAlbums)}`
  2020. )
  2021. }
  2022. private updateCloudAlbumPages(): void {
  2023. this.cloudAlbumPageVersion++
  2024. this.cloudAlbumPages =
  2025. this.buildAlbumPages(`find_cloud_album_section_${this.cloudAlbumPageVersion}`, this.cloudAlbums, this.getDiscoverSectionColumns())
  2026. this.cloudAlbumPageIndex = this.getSafeSectionPageIndex(this.cloudAlbumPages.length, this.cloudAlbumPageIndex)
  2027. Logger.info(
  2028. TAG,
  2029. `FindView updateCloudAlbumPages version=${this.cloudAlbumPageVersion}, pageIndex=${this.cloudAlbumPageIndex}, pages=${this.cloudAlbumPages.length}, albums=${this.buildAlbumSelectionLog(this.cloudAlbums)}`
  2030. )
  2031. }
  2032. private updatePopularSectionPages(): void {
  2033. this.popularSectionPageVersion++
  2034. this.popularSectionPages =
  2035. this.buildSongPages(`find_popular_section_${this.popularSectionPageVersion}`, this.popularSongs, this.getDiscoverSectionColumns())
  2036. this.popularSectionPageIndex = this.getSafeSectionPageIndex(this.popularSectionPages.length, this.popularSectionPageIndex)
  2037. Logger.info(
  2038. TAG,
  2039. `FindView updatePopularSectionPages version=${this.popularSectionPageVersion}, pageIndex=${this.popularSectionPageIndex}, pages=${this.popularSectionPages.length}, songs=${this.buildSongSelectionLog(this.popularSongs)}`
  2040. )
  2041. }
  2042. private getRecentSectionPages(): FindSongPage[] {
  2043. return this.buildSongPages('find_recent_section', this.recentSongs, this.getDiscoverSectionColumns())
  2044. }
  2045. private getFavoriteSectionPages(): FindSongPage[] {
  2046. return this.buildSongPages('find_favorite_section', this.favoriteSongs, this.getDiscoverSectionColumns())
  2047. }
  2048. private getSongTitle(item: VideoItem): string {
  2049. if (StrUtil.isNotEmpty(item.name)) {
  2050. return item.name
  2051. }
  2052. if (StrUtil.isNotEmpty(item.fileName)) {
  2053. return item.fileName as string
  2054. }
  2055. return '未知歌曲'
  2056. }
  2057. private getSongSubtitle(item: VideoItem): string {
  2058. if (StrUtil.isNotEmpty(item.artist) && StrUtil.isNotEmpty(item.album)) {
  2059. return `${item.artist} · ${item.album}`
  2060. }
  2061. if (StrUtil.isNotEmpty(item.artist)) {
  2062. return item.artist as string
  2063. }
  2064. if (StrUtil.isNotEmpty(item.album)) {
  2065. return item.album as string
  2066. }
  2067. return '本地音乐'
  2068. }
  2069. private getSongCover(item: VideoItem): string | Resource {
  2070. return StrUtil.isNotEmpty(item.pixelMapPath) ? item.pixelMapPath as string : $r('app.media.alt')
  2071. }
  2072. private getSongQualityText(item: VideoItem): string {
  2073. if (StrUtil.isNotEmpty(item.md5Str)) {
  2074. const quality = item.md5Str as string
  2075. return quality.includes('Lossless') ? '无损' : quality
  2076. }
  2077. const sampleRate = Number(item.sampleRate ?? '0')
  2078. const bitDepth = Number(item.bits_per_raw_sample ?? '0')
  2079. if (sampleRate >= 88200 || bitDepth >= 24) {
  2080. return 'Hi-Res'
  2081. }
  2082. const mimeType = (item.mimeType ?? '').toLowerCase()
  2083. const fileName = (item.fileName ?? item.name ?? '').toLowerCase()
  2084. const isLosslessFormat = mimeType.includes('flac') || mimeType.includes('wav') || mimeType.includes('ape') ||
  2085. mimeType.includes('alac') || mimeType.includes('dsf') || mimeType.includes('dff') ||
  2086. fileName.endsWith('.flac') || fileName.endsWith('.wav') || fileName.endsWith('.ape') ||
  2087. fileName.endsWith('.alac') || fileName.endsWith('.dsf') || fileName.endsWith('.dff')
  2088. return isLosslessFormat ? '无损' : ''
  2089. }
  2090. private getAlbumCover(coverPath: string): string | Resource {
  2091. return StrUtil.isNotEmpty(coverPath) ? coverPath : $r('app.media.alt')
  2092. }
  2093. private getCloudTypeLabel(item: VideoItem): string {
  2094. switch (item.type) {
  2095. case CommonConstants.TYPE_WEBDAV:
  2096. return 'WebDAV'
  2097. case CommonConstants.TYPE_SMB:
  2098. return 'SMB'
  2099. case CommonConstants.TYPE_NAVIDROME:
  2100. return 'Navidrome'
  2101. case CommonConstants.TYPE_FTP:
  2102. return 'FTP'
  2103. case CommonConstants.TYPE_BAIDU:
  2104. return '百度网盘'
  2105. case CommonConstants.TYPE_JELLYFIN:
  2106. return 'Jellyfin'
  2107. case CommonConstants.TYPE_EMBY:
  2108. return 'Emby'
  2109. case CommonConstants.TYPE_AUDIOSTATION:
  2110. return 'AudioStation'
  2111. case CommonConstants.TYPE_PLEX:
  2112. return 'Plex'
  2113. case CommonConstants.TYPE_DAOLIYU:
  2114. return '道理鱼'
  2115. default:
  2116. return '云端'
  2117. }
  2118. }
  2119. private getRecentSongSubtitle(item: VideoItem): string {
  2120. if (StrUtil.isNotEmpty(item.lastPlayedStr)) {
  2121. return item.lastPlayedStr as string
  2122. }
  2123. return this.getSongSubtitle(item)
  2124. }
  2125. private getSearchSourceLabel(item: VideoItem): string {
  2126. if (item.type === CommonConstants.TYPE_LOCAL) {
  2127. return '本地'
  2128. }
  2129. return this.getCloudTypeLabel(item)
  2130. }
  2131. private toErrorMessage(error: Object): string {
  2132. const businessError = error as BusinessError
  2133. if (businessError && businessError.message) {
  2134. return businessError.message
  2135. }
  2136. const rawError = error as Error
  2137. if (rawError && StrUtil.isNotEmpty(rawError.message)) {
  2138. return rawError.message
  2139. }
  2140. return `${error}`
  2141. }
  2142. private getPrimaryTextColor(): ResourceColor {
  2143. return $r('app.color.text_color')
  2144. }
  2145. private getSecondaryTextColor(): ResourceColor {
  2146. return $r('app.color.find_secondary_text')
  2147. }
  2148. private getCardBackgroundColor(): ResourceColor {
  2149. return $r('app.color.find_card_background')
  2150. }
  2151. private getSongQualityBadgeTextColor(): ResourceColor {
  2152. return $r('app.color.album_detail_song_quality_text')
  2153. }
  2154. private getSongQualityBadgeBackgroundColor(): ResourceColor {
  2155. return $r('app.color.album_detail_song_quality_background')
  2156. }
  2157. @Builder
  2158. private buildLoadingState() {
  2159. Column({ space: 14 }) {
  2160. LoadingProgress()
  2161. .width(46)
  2162. .height(46)
  2163. .color(this.themeColor)
  2164. Text(this.refreshText)
  2165. .fontSize(15)
  2166. .fontWeight(FontWeight.Medium)
  2167. .fontColor(this.getSecondaryTextColor())
  2168. }
  2169. .width('100%')
  2170. .layoutWeight(1)
  2171. .justifyContent(FlexAlign.Center)
  2172. .alignItems(HorizontalAlign.Center)
  2173. }
  2174. @Builder
  2175. private buildTopErrorBanner() {
  2176. if (StrUtil.isNotEmpty(this.refreshText)) {
  2177. if (this.isRefreshing) {
  2178. Row({ space: 8 }) {
  2179. LoadingProgress()
  2180. .width(14)
  2181. .height(14)
  2182. .color(this.themeColor)
  2183. Text(this.refreshText)
  2184. .fontSize(12)
  2185. .fontColor(this.getSecondaryTextColor())
  2186. .textAlign(TextAlign.Center)
  2187. .maxLines(1)
  2188. }
  2189. .width('100%')
  2190. .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  2191. .justifyContent(FlexAlign.Center)
  2192. .alignItems(VerticalAlign.Center)
  2193. } else {
  2194. Row({ space: 6 }) {
  2195. SymbolGlyph($r('sys.symbol.exclamationmark_circle'))
  2196. .fontSize(15)
  2197. .fontColor([$r('app.color.orange')])
  2198. Text(this.refreshText)
  2199. .layoutWeight(1)
  2200. .fontSize(12)
  2201. .fontColor($r('app.color.orange'))
  2202. .maxLines(1)
  2203. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2204. }
  2205. .width('100%')
  2206. .padding({ left: 12, right: 12, top: 10, bottom: 10 })
  2207. .backgroundColor($r('app.color.timeColor_bg'))
  2208. .borderRadius(16)
  2209. }
  2210. }
  2211. }
  2212. @Builder
  2213. private topTitleBar() {
  2214. Column() {
  2215. Row({ space: 12 }) {
  2216. if (!this.isSearchMode && !this.isAlbumMode) {
  2217. TitleBarPointLightButton({
  2218. iconResource: $r('sys.symbol.sort'),
  2219. iconSize: 25,
  2220. pointColor: this.themeColor,
  2221. clickScale: 0.8,
  2222. clickHandler: () => {
  2223. this.getUIContext().animateTo({ duration: 500 }, () => {
  2224. this.isShowDrawer = !this.isShowDrawer
  2225. this.offsetX = 0
  2226. })
  2227. }
  2228. })
  2229. Text('发现页')
  2230. .margin({ left: 3, right: 10 })
  2231. .fontColor($r('app.color.text_color'))
  2232. .fontSize(19)
  2233. .fontWeight(FontWeight.Bold)
  2234. .maxLines(1)
  2235. .textOverflow({ overflow: TextOverflow.MARQUEE })
  2236. .layoutWeight(1)
  2237. } else {
  2238. TitleBarPointLightButton({
  2239. iconResource: $r('sys.symbol.chevron_left'),
  2240. iconSize: 24,
  2241. pointColor: this.themeColor,
  2242. clickScale: 0.8,
  2243. clickHandler: () => {
  2244. if (this.isSearchMode) {
  2245. this.exitSearchMode()
  2246. return
  2247. }
  2248. this.exitAlbumMode()
  2249. }
  2250. })
  2251. if (this.isAlbumMode) {
  2252. Column({ space: 2 }) {
  2253. Text(this.currentAlbumTitle)
  2254. .fontColor($r('app.color.text_color'))
  2255. .fontSize(18)
  2256. .fontWeight(FontWeight.Bold)
  2257. .maxLines(1)
  2258. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2259. .width('100%')
  2260. Text(this.currentAlbumArtist + ' · ' + this.currentAlbumSongs.length + ' 首')
  2261. .fontColor(this.getSecondaryTextColor())
  2262. .fontSize(11)
  2263. .maxLines(1)
  2264. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2265. .width('100%')
  2266. }
  2267. .layoutWeight(1)
  2268. .alignItems(HorizontalAlign.Start)
  2269. .margin({ right: 10 })
  2270. }
  2271. }
  2272. if (this.isSearchMode) {
  2273. Search({ controller: this.searchController, value: this.searchText, placeholder: '搜索本地和网盘歌曲...' })
  2274. .searchButton('搜索', { fontColor: this.themeColor })
  2275. .searchIcon({
  2276. src: $r('sys.media.ohos_ic_public_search_filled')
  2277. })
  2278. .cancelButton({
  2279. style: CancelButtonStyle.CONSTANT,
  2280. icon: {
  2281. src: $r('sys.media.ohos_ic_public_cancel_filled')
  2282. }
  2283. })
  2284. .layoutWeight(1)
  2285. .height(35)
  2286. .maxLength(40)
  2287. .placeholderColor(this.getSecondaryTextColor())
  2288. .placeholderFont({ size: 14, weight: 400 })
  2289. .textFont({ size: 14, weight: 400 })
  2290. .onSubmit((value: string) => {
  2291. this.searchController.stopEditing()
  2292. this.commitSearchHistory(value)
  2293. void this.onSearchInput(value)
  2294. })
  2295. .onChange((value: string) => {
  2296. void this.onSearchInput(value)
  2297. })
  2298. .animation({ duration: 300, curve: Curve.Ease })
  2299. }
  2300. if (!this.isSearchMode && !this.isAlbumMode) {
  2301. TitleBarPointLightButton({
  2302. iconResource: $r('sys.symbol.magnifyingglass'),
  2303. iconSize: 25,
  2304. pointColor: this.themeColor,
  2305. clickScale: 0.6,
  2306. clickHandler: () => {
  2307. this.isAlbumMode = false
  2308. this.getUIContext()?.animateTo({ duration: 500 }, () => {
  2309. this.isSearchMode = true
  2310. })
  2311. this.loadSearchHistory()
  2312. void this.ensureSearchSourceReady()
  2313. }
  2314. })
  2315. }
  2316. }
  2317. }
  2318. .padding({ top: this.topSafeHeight + 10, left: 10, right: 10, bottom: 6 })
  2319. .width('100%')
  2320. }
  2321. @Builder
  2322. private buildSearchLoadingState() {
  2323. Column({ space: 12 }) {
  2324. LoadingProgress()
  2325. .width(38)
  2326. .height(38)
  2327. .color(this.themeColor)
  2328. Text('正在搜索歌曲...')
  2329. .fontSize(14)
  2330. .fontColor(this.getSecondaryTextColor())
  2331. }
  2332. .width('100%')
  2333. .padding({ top: 48, bottom: 48 })
  2334. .alignItems(HorizontalAlign.Center)
  2335. }
  2336. @Builder
  2337. private SearchHistoryView() {
  2338. Column({ space: 10 }) {
  2339. Row() {
  2340. Text('搜索历史')
  2341. .fontSize(14)
  2342. .fontWeight(FontWeight.Medium)
  2343. .fontColor($r('app.color.text_color'))
  2344. Blank()
  2345. Button('清空')
  2346. .fontSize(12)
  2347. .fontColor(this.themeColor)
  2348. .backgroundColor(Color.Transparent)
  2349. .padding({ left: 8, right: 8, top: 4, bottom: 4 })
  2350. .onClick(() => {
  2351. SearchHistoryUtil.clear(this.searchHistoryScope)
  2352. this.searchHistoryItems = []
  2353. })
  2354. }
  2355. .width('100%')
  2356. Flex({ wrap: FlexWrap.Wrap }) {
  2357. ForEach(this.searchHistoryItems, (keyword: string) => {
  2358. PointLightContentButton({
  2359. pointColor: this.themeColor,
  2360. buttonColor: $r('app.color.find_history_background'),
  2361. pointLightHeight: 40,
  2362. builder: () => {
  2363. this.buildSearchHistoryChipContent(keyword)
  2364. }
  2365. })
  2366. .margin({ right: 8, bottom: 8 })
  2367. .padding({ left: 12, right: 12, top: 8, bottom: 8 })
  2368. .onClick(() => {
  2369. this.applySearchHistory(keyword)
  2370. })
  2371. })
  2372. }
  2373. .width('100%')
  2374. }
  2375. .layoutWeight(1)
  2376. .width('100%')
  2377. }
  2378. @Builder
  2379. private buildSearchHistoryChipContent(keyword: string) {
  2380. Text(keyword)
  2381. .fontSize(12)
  2382. .fontColor($r('app.color.text_color'))
  2383. .padding({ left: 8, right: 8, top: 5, bottom: 5 })
  2384. }
  2385. @Builder
  2386. private buildSearchResultCardContent(item: VideoItem) {
  2387. Row({ space: 12 }) {
  2388. Stack({ alignContent: Alignment.BottomStart }) {
  2389. Image(this.getSongCover(item))
  2390. .width(58)
  2391. .height(58)
  2392. .borderRadius(8)
  2393. .objectFit(ImageFit.Cover)
  2394. Row() {
  2395. Text(this.getSearchSourceLabel(item))
  2396. .fontColor($r('app.color.white'))
  2397. .fontSize(8)
  2398. .padding({ left: 5, right: 5, top: 2, bottom: 2 })
  2399. .backgroundColor('#7A000000')
  2400. .borderRadius(999)
  2401. }
  2402. .width('100%')
  2403. .padding({ left: 6, bottom: 6 })
  2404. }
  2405. .width(58)
  2406. .height(58)
  2407. Column({ space: 4 }) {
  2408. Text(this.getSongTitle(item))
  2409. .fontSize(14)
  2410. .fontWeight(FontWeight.Bold)
  2411. .fontColor(this.getPrimaryTextColor())
  2412. .maxLines(1)
  2413. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2414. .width('100%')
  2415. Row (){
  2416. if (StrUtil.isNotEmpty(this.getSongQualityText(item))) {
  2417. Text(this.getSongQualityText(item))
  2418. .fontSize(10)
  2419. .fontWeight(FontWeight.Medium)
  2420. .fontColor(this.getSongQualityBadgeTextColor())
  2421. .padding({ left: 6, right: 6, top: 2, bottom: 2 })
  2422. .backgroundColor(this.getSongQualityBadgeBackgroundColor())
  2423. .borderRadius(4)
  2424. }
  2425. Text(this.getSongSubtitle(item))
  2426. .fontSize(11)
  2427. .lineHeight(14)
  2428. .fontColor(this.getSecondaryTextColor())
  2429. .maxLines(1)
  2430. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2431. .width('100%')
  2432. }
  2433. }
  2434. .layoutWeight(1)
  2435. .alignItems(HorizontalAlign.Start)
  2436. Column({ space: 8 }) {
  2437. PlayingIndicator({
  2438. isActive: this.isPlaying && this.currentSong?.filePath === item.filePath,
  2439. indicatorSize: 18,
  2440. marginRight: 20,
  2441. marginTop: 8,
  2442. marginBottom: 8
  2443. })
  2444. }
  2445. .alignItems(HorizontalAlign.End)
  2446. }
  2447. .width('100%')
  2448. .padding(10)
  2449. }
  2450. @Builder
  2451. private buildSwipeDeleteAction(item: VideoItem) {
  2452. Row() {
  2453. Button('删除')
  2454. .width(72)
  2455. .height(52)
  2456. .fontSize(14)
  2457. .fontColor(Color.White)
  2458. .backgroundColor($r('app.color.btn_red'))
  2459. .borderRadius(14)
  2460. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
  2461. .onClick(() => {
  2462. this.openDeleteSongDialog(item)
  2463. })
  2464. }
  2465. .padding({ left: 8, right: 4 })
  2466. }
  2467. @Builder
  2468. private buildSearchContent() {
  2469. List({ space: 12 }) {
  2470. if (this.isSearchLoading) {
  2471. ListItem() {
  2472. this.buildSearchLoadingState()
  2473. }
  2474. } else if (this.searchText.length === 0 && this.searchHistoryItems.length > 0) {
  2475. ListItem() {
  2476. this.SearchHistoryView()
  2477. }
  2478. } else if (this.searchText.length === 0) {
  2479. ListItem() {
  2480. this.buildSectionEmptyState('搜索本地和网盘歌曲', '输入歌曲名、歌手或专辑名后即可开始搜索', false)
  2481. }
  2482. } else if (this.searchResults.length === 0) {
  2483. ListItem() {
  2484. this.buildSectionEmptyState('没有找到匹配歌曲', '试试其他关键词,结果会同时包含本地和网盘歌曲', false)
  2485. }
  2486. } else {
  2487. ListItem() {
  2488. Row() {
  2489. Text('搜索结果')
  2490. .fontSize(14)
  2491. .fontWeight(FontWeight.Medium)
  2492. .fontColor(this.getSecondaryTextColor())
  2493. Blank()
  2494. Text(`${this.searchResults.length} 首`)
  2495. .fontSize(12)
  2496. .fontColor(this.getSecondaryTextColor())
  2497. }
  2498. .width('100%')
  2499. }
  2500. ForEach(this.searchResults, (item: VideoItem, index: number) => {
  2501. ListItem() {
  2502. PointLightContentButton({
  2503. pointColor: this.themeColor,
  2504. buttonColor: this.getCardBackgroundColor(),
  2505. buttonRadius: 18,
  2506. pointLightHeight: 132,
  2507. useShadow: true,
  2508. builder: () => {
  2509. this.buildSearchResultCardContent(item)
  2510. }
  2511. })
  2512. .width('100%')
  2513. .onClick(() => {
  2514. this.emitPlaylistPlay('find-search', '发现搜索', this.searchResults, index)
  2515. })
  2516. }
  2517. .swipeAction(this.canDeleteSong(item)
  2518. ? {
  2519. end: this.buildSwipeDeleteAction(item),
  2520. edgeEffect: SwipeEdgeEffect.None
  2521. }
  2522. : {})
  2523. .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.LongPress,
  2524. {
  2525. preview: MenuPreviewMode.IMAGE
  2526. })
  2527. .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.RightClick,
  2528. {
  2529. preview: MenuPreviewMode.IMAGE
  2530. })
  2531. }, getFindSongKey)
  2532. }
  2533. }
  2534. .width('100%')
  2535. .height('100%')
  2536. .padding({ left: 12, right: 12, top: this.topSafeHeight + 65 })
  2537. .scrollBar(BarState.Off)
  2538. .edgeEffect(EdgeEffect.Spring)
  2539. .layoutWeight(1)
  2540. }
  2541. @Builder
  2542. private buildSwiperCardContent(item: VideoItem) {
  2543. Stack({ alignContent: Alignment.BottomStart }) {
  2544. Image(this.getSongCover(item))
  2545. .width('100%')
  2546. .aspectRatio(this.getSwiperAspectRatio())
  2547. .objectFit(ImageFit.Cover)
  2548. Column({ space: 10 }) {
  2549. Column({ space: 4 }) {
  2550. Text(this.getSongTitle(item))
  2551. .fontColor($r('app.color.white'))
  2552. .fontSize(this.getSwiperTextSize(18, 24))
  2553. .fontWeight(FontWeight.Bold)
  2554. .maxLines(1)
  2555. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2556. Text(this.getSongSubtitle(item))
  2557. .fontColor('#E6FFFFFF')
  2558. .fontSize(this.getSwiperTextSize(12, 15))
  2559. .maxLines(1)
  2560. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2561. }
  2562. .alignItems(HorizontalAlign.Start)
  2563. }
  2564. .width('100%')
  2565. .padding({
  2566. left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22,
  2567. right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22,
  2568. bottom: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22
  2569. })
  2570. .linearGradient({
  2571. direction: GradientDirection.Top,
  2572. colors: [['#D9000000', 0], ['#7A000000', 0.48], ['#00000000', 1]]
  2573. })
  2574. }
  2575. .width('100%')
  2576. .clip(true)
  2577. }
  2578. @Builder
  2579. private buildSwiperSection() {
  2580. if (this.swiperSongs.length > 0) {
  2581. Swiper() {
  2582. ForEach(this.swiperSongs, (item: VideoItem, index: number) => {
  2583. PointLightContentButton({
  2584. pointColor: this.themeColor,
  2585. buttonRadius: 0,
  2586. pointLightHeight: 180,
  2587. builder: () => {
  2588. this.buildSwiperCardContent(item)
  2589. }
  2590. })
  2591. .width('100%')
  2592. .onClick(() => {
  2593. this.handleSwiperTap(index)
  2594. })
  2595. }, getFindSongKey)
  2596. }
  2597. .width('100%')
  2598. .clip(true)
  2599. .displayCount(this.getTopSwiperDisplayCount())
  2600. .autoPlay(true)
  2601. .interval(3200)
  2602. .loop(true)
  2603. .onChange((index: number) => {
  2604. this.swiperIndex = index
  2605. })
  2606. .indicator(new DotIndicator()
  2607. .itemWidth(this.getIndicatorItemWidth())
  2608. .itemHeight(2)
  2609. .selectedItemWidth(this.getIndicatorItemWidth())
  2610. .selectedItemHeight(4))
  2611. } else {
  2612. this.buildSectionEmptyState('还没有可展示的封面音乐', '给本地歌曲补上封面后,这里会自动随机推荐', true)
  2613. }
  2614. }
  2615. private getSwiperDisplayCount(): number {
  2616. return new BreakpointType<number>({
  2617. sm: 1,
  2618. md: 1,
  2619. lg: 1,
  2620. xl: 2,
  2621. xxl: 2
  2622. }).getValue(this.currentBreakpoint) ?? 1
  2623. }
  2624. private getTopSwiperDisplayCount(): number {
  2625. return new BreakpointType<number>({
  2626. sm: 1,
  2627. md: 2,
  2628. lg: 2,
  2629. xl: 3,
  2630. xxl: 3
  2631. }).getValue(this.currentBreakpoint) ?? 1
  2632. }
  2633. @Builder
  2634. private buildActionButtons() {
  2635. Row({ space: 10 }) {
  2636. PointLightActionButton({
  2637. text: '最近爱听',
  2638. iconResource: $r('sys.symbol.heart'),
  2639. pointColor: this.themeColor,
  2640. textColor: $r('app.color.text_color'),
  2641. buttonColor: this.getCardBackgroundColor()
  2642. })
  2643. .opacity(0.8)
  2644. .layoutWeight(1)
  2645. .onClick(() => {
  2646. void this.handleActionButtonTap('top')
  2647. })
  2648. PointLightActionButton({
  2649. text: '随心所欲',
  2650. iconResource: $r('sys.symbol.shuffle'),
  2651. pointColor: this.themeColor,
  2652. textColor: $r('app.color.text_color'),
  2653. buttonColor: this.getCardBackgroundColor()
  2654. })
  2655. .opacity(0.8)
  2656. .layoutWeight(1)
  2657. .onClick(() => {
  2658. void this.handleActionButtonTap('local-random')
  2659. })
  2660. PointLightActionButton({
  2661. text: '云端漫游',
  2662. iconResource: $r('sys.symbol.cloud'),
  2663. pointColor: this.themeColor,
  2664. textColor: $r('app.color.text_color'),
  2665. buttonColor: this.getCardBackgroundColor()
  2666. })
  2667. .opacity(0.8)
  2668. .layoutWeight(1)
  2669. .onClick(() => {
  2670. void this.handleActionButtonTap('cloud-random')
  2671. })
  2672. }
  2673. .width('100%')
  2674. }
  2675. @Builder
  2676. private buildCloudSongCardContent(item: VideoItem) {
  2677. Column({ space: 6 }) {
  2678. Stack({ alignContent: Alignment.Bottom }) {
  2679. Image(this.getSongCover(item))
  2680. .width('100%')
  2681. .aspectRatio(3 / 4)
  2682. .borderRadius(16)
  2683. .alt($r('app.media.alt'))
  2684. .objectFit(ImageFit.Cover)
  2685. Row() {
  2686. Text(this.getCloudTypeLabel(item))
  2687. .fontColor($r('app.color.white'))
  2688. .fontWeight(FontWeight.Medium)
  2689. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2690. .fontSize(9)
  2691. .backgroundColor('#7A000000')
  2692. .borderRadius(999)
  2693. }
  2694. .width('100%')
  2695. .padding({ left: 8, right: 8, bottom: 8 })
  2696. .justifyContent(FlexAlign.Start)
  2697. }
  2698. Text(this.getSongTitle(item))
  2699. .fontSize(14)
  2700. .fontWeight(FontWeight.Bold)
  2701. .lineHeight(18)
  2702. .fontColor(this.getPrimaryTextColor())
  2703. .maxLines(1)
  2704. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2705. .width('100%')
  2706. Text(this.getSongSubtitle(item))
  2707. .fontSize(11)
  2708. .lineHeight(14)
  2709. .fontColor(this.getSecondaryTextColor())
  2710. .maxLines(1)
  2711. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2712. .width('100%')
  2713. }
  2714. .width('100%')
  2715. }
  2716. @Builder
  2717. private buildCloudSection() {
  2718. ConfigTitle({
  2719. text: '漫步云端',
  2720. indicatorCount: this.cloudSectionPages.length,
  2721. indicatorIndex: this.getSafeSectionPageIndex(this.cloudSectionPages.length, this.cloudSectionPageIndex),
  2722. onActionClick: () => {
  2723. Logger.info(TAG, `FindView clickChange cloudSection current=${this.buildSongSelectionLog(this.remoteSongs)}`)
  2724. this.refreshCloudSongs()
  2725. }
  2726. })
  2727. if (this.remoteSongs.length === 0) {
  2728. this.buildSectionEmptyState('还没有网盘歌曲', '先去网盘页或远程音乐页加载歌曲,这里会自动展示', false)
  2729. } else {
  2730. Swiper() {
  2731. ForEach(this.cloudSectionPages, (page: FindSongPage, pageIndex: number) => {
  2732. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  2733. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  2734. GridCol() {
  2735. PointLightContentButton({
  2736. pointColor: this.themeColor,
  2737. buttonRadius: 16,
  2738. builder: () => {
  2739. this.buildCloudSongCardContent(item)
  2740. }
  2741. })
  2742. .width('100%')
  2743. .onClick(() => {
  2744. this.handleRemoteSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  2745. })
  2746. }
  2747. }, getFindSongKey)
  2748. }
  2749. }, getFindSongPageKey)
  2750. }
  2751. .width('100%')
  2752. .indicator(false)
  2753. .autoPlay(false)
  2754. .displayCount(this.getSwiperDisplayCount())
  2755. .loop(false)
  2756. .onChange((index: number) => {
  2757. this.cloudSectionPageIndex = index
  2758. })
  2759. }
  2760. }
  2761. @Builder
  2762. private buildAlbumCardContent(album: FindAlbumGroup) {
  2763. Column({ space: 6 }) {
  2764. Stack({ alignContent: Alignment.Bottom }) {
  2765. Image(this.getAlbumCover(album.coverPath))
  2766. .width('100%')
  2767. .aspectRatio(3 / 4)
  2768. .borderRadius(18)
  2769. .alt($r('app.media.alt'))
  2770. .objectFit(ImageFit.Cover)
  2771. Row() {
  2772. Text(album.sourceLabel)
  2773. .fontColor($r('app.color.white'))
  2774. .fontWeight(FontWeight.Medium)
  2775. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2776. .fontSize(9)
  2777. .backgroundColor('#7A000000')
  2778. .borderRadius(999)
  2779. .visibility(album.sourceLabel === '本地' ? Visibility.None : Visibility.Visible)
  2780. Text(album.songCount + ' 首')
  2781. .fontColor($r('app.color.white'))
  2782. .fontSize(9)
  2783. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2784. .backgroundColor('#5C000000')
  2785. .borderRadius(999)
  2786. }
  2787. .width('100%')
  2788. .padding({ left: 8, right: 8, bottom: 8 })
  2789. .justifyContent(FlexAlign.SpaceBetween)
  2790. }
  2791. Text(album.title)
  2792. .fontSize(14)
  2793. .fontWeight(FontWeight.Bold)
  2794. .lineHeight(18)
  2795. .fontColor(this.getPrimaryTextColor())
  2796. .maxLines(1)
  2797. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2798. .width('100%')
  2799. Text(album.artist)
  2800. .fontSize(11)
  2801. .lineHeight(14)
  2802. .fontColor(this.getSecondaryTextColor())
  2803. .maxLines(1)
  2804. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2805. .width('100%')
  2806. }
  2807. .width('100%')
  2808. }
  2809. @Builder
  2810. private buildFeaturedAlbumSection() {
  2811. ConfigTitle({
  2812. text: '精选专辑',
  2813. indicatorCount: this.featuredAlbumPages.length,
  2814. indicatorIndex: this.getSafeSectionPageIndex(this.featuredAlbumPages.length, this.featuredAlbumPageIndex),
  2815. onActionClick: () => {
  2816. Logger.info(TAG, `FindView clickChange featuredAlbums current=${this.buildAlbumSelectionLog(this.featuredAlbums)}`)
  2817. this.refreshFeaturedAlbums()
  2818. }
  2819. })
  2820. if (this.featuredAlbums.length === 0) {
  2821. this.buildSectionEmptyState('还没有可展示的专辑', '带专辑信息的本地歌曲会自动整理到这里', false)
  2822. } else {
  2823. Swiper() {
  2824. ForEach(this.featuredAlbumPages, (page: FindAlbumPage) => {
  2825. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  2826. ForEach(page.items, (album: FindAlbumGroup) => {
  2827. GridCol() {
  2828. PointLightContentButton({
  2829. pointColor: this.themeColor,
  2830. buttonRadius: 18,
  2831. builder: () => {
  2832. this.buildAlbumCardContent(album)
  2833. }
  2834. })
  2835. .width('100%')
  2836. .onClick(() => {
  2837. this.openAlbumDetail(album)
  2838. })
  2839. }
  2840. }, getFindAlbumKey)
  2841. }
  2842. }, getFindAlbumPageKey)
  2843. }
  2844. .width('100%')
  2845. .indicator(false)
  2846. .autoPlay(false)
  2847. .loop(false)
  2848. .displayCount(this.getSwiperDisplayCount())
  2849. .onChange((index: number) => {
  2850. this.featuredAlbumPageIndex = index
  2851. })
  2852. }
  2853. }
  2854. @Builder
  2855. private buildCloudAlbumSection() {
  2856. ConfigTitle({
  2857. text: '云端专辑',
  2858. indicatorCount: this.cloudAlbumPages.length,
  2859. indicatorIndex: this.getSafeSectionPageIndex(this.cloudAlbumPages.length, this.cloudAlbumPageIndex),
  2860. onActionClick: () => {
  2861. Logger.info(TAG, `FindView clickChange cloudAlbums current=${this.buildAlbumSelectionLog(this.cloudAlbums)}`)
  2862. this.refreshCloudAlbums()
  2863. }
  2864. })
  2865. if (this.cloudAlbums.length === 0) {
  2866. this.buildSectionEmptyState('云端歌曲还没有专辑信息', '网盘歌曲带有专辑标签后,这里会自动汇总', false)
  2867. } else {
  2868. Swiper() {
  2869. ForEach(this.cloudAlbumPages, (page: FindAlbumPage) => {
  2870. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  2871. ForEach(page.items, (album: FindAlbumGroup) => {
  2872. GridCol() {
  2873. PointLightContentButton({
  2874. pointColor: this.themeColor,
  2875. buttonRadius: 18,
  2876. builder: () => {
  2877. this.buildAlbumCardContent(album)
  2878. }
  2879. })
  2880. .width('100%')
  2881. .onClick(() => {
  2882. this.openAlbumDetail(album)
  2883. })
  2884. }
  2885. }, getFindAlbumKey)
  2886. }
  2887. }, getFindAlbumPageKey)
  2888. }
  2889. .width('100%')
  2890. .indicator(false)
  2891. .autoPlay(false)
  2892. .loop(false)
  2893. .displayCount(this.getSwiperDisplayCount())
  2894. .onChange((index: number) => {
  2895. this.cloudAlbumPageIndex = index
  2896. })
  2897. }
  2898. }
  2899. @Builder
  2900. private buildHotSongCardContent(item: VideoItem) {
  2901. Column({ space: 5 }) {
  2902. Stack({ alignContent: Alignment.Bottom }) {
  2903. Image(this.getSongCover(item))
  2904. .aspectRatio(3 / 4)
  2905. .borderRadius(16)
  2906. .width(160)
  2907. .alt($r('app.media.alt'))
  2908. .objectFit(ImageFit.Cover)
  2909. Row() {
  2910. Text(this.getSongSubtitle(item))
  2911. .fontColor($r('app.color.white'))
  2912. .fontSize(9)
  2913. .lineHeight(11)
  2914. .maxLines(1)
  2915. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2916. }
  2917. .justifyContent(FlexAlign.End)
  2918. .padding({ left: 8, right: 8, bottom: 9 })
  2919. .width(160)
  2920. }
  2921. Text(this.getSongTitle(item))
  2922. .lineHeight(16)
  2923. .maxLines(1)
  2924. .width(160)
  2925. .fontColor(this.getPrimaryTextColor())
  2926. .fontSize(14)
  2927. .fontWeight(FontWeight.Bold)
  2928. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2929. Text(this.getRecentSongSubtitle(item))
  2930. .fontColor(this.getSecondaryTextColor())
  2931. .fontSize(10)
  2932. .maxLines(1)
  2933. .textOverflow({ overflow: TextOverflow.Ellipsis })
  2934. .lineHeight(11)
  2935. .width(160)
  2936. .textAlign(TextAlign.Start)
  2937. }
  2938. }
  2939. @Builder
  2940. private buildLocalRandomSection() {
  2941. ConfigTitle({
  2942. text: '今日乐曲',
  2943. onActionClick: () => {
  2944. this.refreshLocalRandomSongs()
  2945. }
  2946. })
  2947. if (this.hotSongs.length === 0) {
  2948. this.buildSectionEmptyState('本地随机还没有内容', '本地曲库加载完成后,这里会随机展示歌曲', false)
  2949. } else {
  2950. List({ space: 8 }) {
  2951. ForEach(this.hotSongs, (item: VideoItem, index: number) => {
  2952. ListItem() {
  2953. PointLightContentButton({
  2954. pointColor: this.themeColor,
  2955. buttonRadius: 16,
  2956. builder: () => {
  2957. this.buildHotSongCardContent(item)
  2958. }
  2959. })
  2960. .onClick(() => {
  2961. this.handleHotSongTap(index)
  2962. })
  2963. }
  2964. .margin({
  2965. left: index === 0 ? 2 : 0,
  2966. right: index === this.hotSongs.length - 1 ? 2 : 0
  2967. })
  2968. }, getFindSongKey)
  2969. }
  2970. .listDirection(Axis.Horizontal)
  2971. .scrollBar(BarState.Off)
  2972. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  2973. }
  2974. }
  2975. @Builder
  2976. private buildHeartPlaylistCardContent(playlist: Playlist) {
  2977. Column({ space: 6 }) {
  2978. Stack({ alignContent: Alignment.Bottom }) {
  2979. Image(this.getPlaylistCover(playlist))
  2980. .aspectRatio(3 / 4)
  2981. .borderRadius(16)
  2982. .width(160)
  2983. .alt($r('app.media.alt'))
  2984. .objectFit(ImageFit.Cover)
  2985. Row() {
  2986. Text('心动歌单')
  2987. .fontColor($r('app.color.white'))
  2988. .fontSize(9)
  2989. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2990. .backgroundColor('#7A000000')
  2991. .borderRadius(999)
  2992. Text(`${playlist.songCount} 首`)
  2993. .fontColor($r('app.color.white'))
  2994. .fontSize(9)
  2995. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2996. .backgroundColor('#5C000000')
  2997. .borderRadius(999)
  2998. }
  2999. .justifyContent(FlexAlign.SpaceBetween)
  3000. .padding({ left: 8, right: 8, bottom: 8 })
  3001. .width(160)
  3002. }
  3003. Text(playlist.name)
  3004. .lineHeight(16)
  3005. .maxLines(1)
  3006. .width(160)
  3007. .fontColor(this.getPrimaryTextColor())
  3008. .fontSize(14)
  3009. .fontWeight(FontWeight.Bold)
  3010. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3011. Text(this.getPlaylistSubtitle(playlist))
  3012. .fontColor(this.getSecondaryTextColor())
  3013. .fontSize(10)
  3014. .maxLines(1)
  3015. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3016. .lineHeight(11)
  3017. .width(160)
  3018. .textAlign(TextAlign.Start)
  3019. }
  3020. }
  3021. @Builder
  3022. private buildHeartPlaylistSection() {
  3023. ConfigTitle({
  3024. text: '心动歌单',
  3025. showAction: false
  3026. })
  3027. List({ space: 8 }) {
  3028. ForEach(this.heartPlaylists, (playlist: Playlist) => {
  3029. ListItem() {
  3030. PointLightContentButton({
  3031. pointColor: this.themeColor,
  3032. buttonRadius: 16,
  3033. builder: () => {
  3034. this.buildHeartPlaylistCardContent(playlist)
  3035. }
  3036. })
  3037. .onClick(() => {
  3038. void this.openPlaylistDetail(playlist)
  3039. })
  3040. }
  3041. }, getFindPlaylistKey)
  3042. }
  3043. .listDirection(Axis.Horizontal)
  3044. .scrollBar(BarState.Off)
  3045. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  3046. }
  3047. @Builder
  3048. private buildCloudMoodSection() {
  3049. ConfigTitle({
  3050. text: '云卷云舒',
  3051. onActionClick: () => {
  3052. this.refreshCloudMoodSongs()
  3053. }
  3054. })
  3055. if (this.cloudMoodSongs.length === 0) {
  3056. this.buildSectionEmptyState('网盘随机还没有内容', '网盘歌曲加载完成后,这里会随机展示歌曲', false)
  3057. } else {
  3058. List({ space: 8 }) {
  3059. ForEach(this.cloudMoodSongs, (item: VideoItem, index: number) => {
  3060. ListItem() {
  3061. PointLightContentButton({
  3062. pointColor: this.themeColor,
  3063. buttonRadius: 16,
  3064. builder: () => {
  3065. this.buildHotSongCardContent(item)
  3066. }
  3067. })
  3068. .onClick(() => {
  3069. this.handleCloudMoodSongTap(index)
  3070. })
  3071. }
  3072. .margin({
  3073. left: index === 0 ? 2 : 0,
  3074. right: index === this.cloudMoodSongs.length - 1 ? 2 : 0
  3075. })
  3076. }, getFindSongKey)
  3077. }
  3078. .listDirection(Axis.Horizontal)
  3079. .scrollBar(BarState.Off)
  3080. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  3081. }
  3082. }
  3083. @Builder
  3084. private buildRecentSongCardContent(item: VideoItem) {
  3085. Column({ space: 6 }) {
  3086. Stack({ alignContent: Alignment.Bottom }) {
  3087. Image(this.getSongCover(item))
  3088. .width('100%')
  3089. .aspectRatio(3 / 4)
  3090. .borderRadius(16)
  3091. .objectFit(ImageFit.Cover)
  3092. Row() {
  3093. Text(item.duration ? item.duration : '')
  3094. .fontColor($r('app.color.white'))
  3095. .fontSize(9)
  3096. .visibility(item.duration ? Visibility.Visible : Visibility.None)
  3097. }
  3098. .width('100%')
  3099. .padding({ left: 8, right: 8, bottom: 8 })
  3100. .justifyContent(FlexAlign.SpaceBetween)
  3101. }
  3102. Text(this.getSongTitle(item))
  3103. .fontSize(13)
  3104. .fontWeight(FontWeight.Bold)
  3105. .lineHeight(17)
  3106. .fontColor(this.getPrimaryTextColor())
  3107. .maxLines(1)
  3108. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3109. .width('100%')
  3110. Text(this.getRecentSongSubtitle(item))
  3111. .fontSize(10)
  3112. .lineHeight(13)
  3113. .fontColor(this.getSecondaryTextColor())
  3114. .maxLines(1)
  3115. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3116. .width('100%')
  3117. }
  3118. .width('100%')
  3119. }
  3120. @Builder
  3121. private buildRecentSection() {
  3122. ConfigTitle({
  3123. text: '最近播放',
  3124. actionText: '更多',
  3125. actionSymbol: $r('sys.symbol.chevron_right'),
  3126. indicatorCount: this.getRecentSectionPages().length,
  3127. indicatorIndex: this.getSafeSectionPageIndex(this.getRecentSectionPages().length, this.recentSectionPageIndex),
  3128. onActionClick: () => {
  3129. this.openLocalSpecialList('recent')
  3130. }
  3131. })
  3132. if (this.recentSongs.length === 0) {
  3133. this.buildSectionEmptyState('最近播放还是空的', '播放过的歌曲会自动出现在这里', false)
  3134. } else {
  3135. Swiper() {
  3136. ForEach(this.getRecentSectionPages(), (page: FindSongPage, pageIndex: number) => {
  3137. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3138. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3139. GridCol() {
  3140. PointLightContentButton({
  3141. pointColor: this.themeColor,
  3142. buttonRadius: 16,
  3143. builder: () => {
  3144. this.buildRecentSongCardContent(item)
  3145. }
  3146. })
  3147. .width('100%')
  3148. .onClick(() => {
  3149. this.handleRecentSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3150. })
  3151. }
  3152. }, getFindSongKey)
  3153. }
  3154. }, getFindSongPageKey)
  3155. }
  3156. .width('100%')
  3157. .indicator(false)
  3158. .autoPlay(false)
  3159. .displayCount(this.getSwiperDisplayCount())
  3160. .loop(false)
  3161. .onChange((index: number) => {
  3162. this.recentSectionPageIndex = index
  3163. })
  3164. }
  3165. }
  3166. @Builder
  3167. private buildPopularSongCardContent(item: VideoItem) {
  3168. Column({ space: 6 }) {
  3169. Stack({ alignContent: Alignment.Bottom }) {
  3170. Image(this.getSongCover(item))
  3171. .aspectRatio(0.8)
  3172. .objectFit(ImageFit.Cover)
  3173. .width('100%')
  3174. .borderRadius(16)
  3175. Row() {
  3176. Text(item.playCount ? item.playCount + ' 次' : '')
  3177. .fontColor($r('app.color.white'))
  3178. .fontSize(9)
  3179. .lineHeight(11)
  3180. .visibility(item.playCount ? Visibility.Visible : Visibility.None)
  3181. }
  3182. .width('100%')
  3183. .justifyContent(FlexAlign.SpaceBetween)
  3184. .padding({ left: 8, right: 8, bottom: 8 })
  3185. }
  3186. Text(this.getSongTitle(item))
  3187. .fontColor(this.getPrimaryTextColor())
  3188. .maxLines(1)
  3189. .fontSize(14)
  3190. .lineHeight(16)
  3191. .fontWeight(FontWeight.Bold)
  3192. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3193. .width('100%')
  3194. Text(this.getSongSubtitle(item))
  3195. .fontColor(this.getSecondaryTextColor())
  3196. .textAlign(TextAlign.Start)
  3197. .maxLines(1)
  3198. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3199. .fontSize(10)
  3200. .lineHeight(11)
  3201. .width('100%')
  3202. }
  3203. .width('100%')
  3204. }
  3205. @Builder
  3206. private buildPopularSection() {
  3207. ConfigTitle({
  3208. text: '热门歌曲',
  3209. indicatorCount: this.popularSectionPages.length,
  3210. indicatorIndex: this.getSafeSectionPageIndex(this.popularSectionPages.length, this.popularSectionPageIndex),
  3211. onActionClick: () => {
  3212. Logger.info(TAG, `FindView clickChange popularSongs current=${this.buildSongSelectionLog(this.popularSongs)}`)
  3213. this.refreshPopularSongs()
  3214. }
  3215. })
  3216. if (this.popularSongs.length === 0) {
  3217. this.buildSectionEmptyState('热门歌曲还没生成', '播放次数高的歌曲会优先展示在这里', false)
  3218. } else {
  3219. Swiper() {
  3220. ForEach(this.popularSectionPages, (page: FindSongPage, pageIndex: number) => {
  3221. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3222. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3223. GridCol() {
  3224. PointLightContentButton({
  3225. pointColor: this.themeColor,
  3226. buttonRadius: 16,
  3227. builder: () => {
  3228. this.buildPopularSongCardContent(item)
  3229. }
  3230. })
  3231. .width('100%')
  3232. .onClick(() => {
  3233. this.handlePopularSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3234. })
  3235. }
  3236. }, getFindSongKey)
  3237. }
  3238. }, getFindSongPageKey)
  3239. }
  3240. .width('100%')
  3241. .indicator(false)
  3242. .autoPlay(false)
  3243. .loop(false)
  3244. .displayCount(this.getSwiperDisplayCount())
  3245. .onChange((index: number) => {
  3246. this.popularSectionPageIndex = index
  3247. })
  3248. }
  3249. }
  3250. @Builder
  3251. private buildFavoriteSongCardContent(item: VideoItem) {
  3252. Column({ space: 6 }) {
  3253. Stack({ alignContent: Alignment.Bottom }) {
  3254. Image(this.getSongCover(item))
  3255. .width('100%')
  3256. .aspectRatio(3 / 4)
  3257. .borderRadius(16)
  3258. .objectFit(ImageFit.Cover)
  3259. Row() {
  3260. Text(item.duration ? item.duration : '')
  3261. .fontColor($r('app.color.white'))
  3262. .fontSize(9)
  3263. .visibility(item.duration ? Visibility.Visible : Visibility.None)
  3264. }
  3265. .width('100%')
  3266. .padding({ left: 8, right: 8, bottom: 8 })
  3267. .justifyContent(FlexAlign.SpaceBetween)
  3268. }
  3269. Text(this.getSongTitle(item))
  3270. .fontSize(13)
  3271. .fontWeight(FontWeight.Bold)
  3272. .lineHeight(17)
  3273. .fontColor(this.getPrimaryTextColor())
  3274. .maxLines(1)
  3275. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3276. .width('100%')
  3277. Text(this.getSongSubtitle(item))
  3278. .fontSize(10)
  3279. .lineHeight(13)
  3280. .fontColor(this.getSecondaryTextColor())
  3281. .maxLines(1)
  3282. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3283. .width('100%')
  3284. }
  3285. .width('100%')
  3286. }
  3287. @Builder
  3288. private buildFavoriteSection() {
  3289. ConfigTitle({
  3290. text: '我的收藏',
  3291. actionText: '更多',
  3292. actionSymbol: $r('sys.symbol.chevron_right'),
  3293. indicatorCount: this.getFavoriteSectionPages().length,
  3294. indicatorIndex: this.getSafeSectionPageIndex(this.getFavoriteSectionPages().length, this.favoriteSectionPageIndex),
  3295. onActionClick: () => {
  3296. this.openLocalSpecialList('favorite')
  3297. }
  3298. })
  3299. if (this.favoriteSongs.length === 0) {
  3300. this.buildSectionEmptyState('我的收藏还是空的', '', false)
  3301. } else {
  3302. Swiper() {
  3303. ForEach(this.getFavoriteSectionPages(), (page: FindSongPage, pageIndex: number) => {
  3304. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3305. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3306. GridCol() {
  3307. PointLightContentButton({
  3308. pointColor: this.themeColor,
  3309. buttonRadius: 16,
  3310. builder: () => {
  3311. this.buildFavoriteSongCardContent(item)
  3312. }
  3313. })
  3314. .width('100%')
  3315. .onClick(() => {
  3316. this.handleFavoriteSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3317. })
  3318. }
  3319. }, getFindSongKey)
  3320. }
  3321. }, getFindSongPageKey)
  3322. }
  3323. .width('100%')
  3324. .indicator(false)
  3325. .autoPlay(false)
  3326. .displayCount(this.getSwiperDisplayCount())
  3327. .loop(false)
  3328. .onChange((index: number) => {
  3329. this.favoriteSectionPageIndex = index
  3330. })
  3331. }
  3332. }
  3333. @Builder
  3334. private buildSectionEmptyState(title: string, subtitle: string, useLargeCard: boolean) {
  3335. Column({ space: 8 }) {
  3336. SymbolGlyph($r('sys.symbol.music'))
  3337. .attributeModifier(new SymbolGlyphFancyModifier(useLargeCard ?70 :42, '', ''))
  3338. .opacity(0.72)
  3339. Text(title)
  3340. .fontSize(15)
  3341. .fontWeight(FontWeight.Medium)
  3342. .fontColor(this.getPrimaryTextColor())
  3343. Text(subtitle)
  3344. .fontSize(12)
  3345. .fontColor(this.getSecondaryTextColor())
  3346. .textAlign(TextAlign.Center)
  3347. }
  3348. .width('100%')
  3349. .padding({ top: useLargeCard ? 42 : 26, bottom: useLargeCard ? 42 : 26, left: 12, right: 12 })
  3350. .borderRadius(20)
  3351. .justifyContent(FlexAlign.Center)
  3352. .alignItems(HorizontalAlign.Center)
  3353. }
  3354. @Builder
  3355. private buildDiscoveryContent() {
  3356. Scroll() {
  3357. Column() {
  3358. this.buildTopErrorBanner()
  3359. this.buildSwiperSection()
  3360. Column({ space: 6 }) {
  3361. this.buildActionButtons()
  3362. this.buildLocalRandomSection()
  3363. if (this.favoriteSongsPool.length > 0 || this.favoriteSongs.length > 0) {
  3364. this.buildFavoriteSection()
  3365. }
  3366. if (this.heartPlaylists.length > 0) {
  3367. this.buildHeartPlaylistSection()
  3368. }
  3369. if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) {
  3370. this.buildCloudSection()
  3371. }
  3372. this.buildFeaturedAlbumSection()
  3373. if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) {
  3374. this.buildCloudMoodSection()
  3375. }
  3376. if (this.remoteSongsPool.length > 0 || this.cloudAlbumsPool.length > 0 || this.cloudAlbums.length > 0) {
  3377. this.buildCloudAlbumSection()
  3378. }
  3379. this.buildRecentSection()
  3380. this.buildPopularSection()
  3381. }
  3382. .width('100%')
  3383. .padding({
  3384. left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
  3385. right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
  3386. top: 16,
  3387. bottom: this.bottomSafeHeight + 96
  3388. })
  3389. .alignItems(HorizontalAlign.Start)
  3390. }
  3391. }
  3392. .scrollBar(BarState.Off)
  3393. .edgeEffect(EdgeEffect.Spring)
  3394. .width('100%')
  3395. .layoutWeight(1)
  3396. }
  3397. build() {
  3398. Stack() {
  3399. this.buildContentView();
  3400. // 顶部区域:标题栏在上,面包屑在下
  3401. Column() {
  3402. this.topTitleBar()
  3403. }
  3404. .backgroundColor(Color.Transparent)
  3405. .width('100%')
  3406. .visibility(this.isAlbumMode ? Visibility.None : (this.isShowTitleBar ? Visibility.Visible :
  3407. this.autoHideTitle ? Visibility.None : Visibility.Visible))
  3408. .animation({
  3409. duration: 500,
  3410. curve: Curve.Friction // 可选动画曲线
  3411. })
  3412. .position({ top: 0, left: 0 })
  3413. // .backgroundColor($r('app.color.start_window_background'))
  3414. }
  3415. .width('100%')
  3416. .height('100%')
  3417. .alignContent(Alignment.Bottom)
  3418. .backgroundImage(this.isMusicCoverHomeBackgroundActive()
  3419. ? this.customizeMusicBgPath
  3420. : (this.isCustomizeBg ? this.customizeBgPath : $r('app.color.start_window_background')))
  3421. .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'})
  3422. .backgroundImagePosition(Alignment.Center)
  3423. .backdropBlur(this.blurValue)
  3424. .backgroundBrightness({rate:this.isMusicCoverHomeBackgroundActive() || this.isCustomizeBg ? 0.1 : 0,
  3425. lightUpDegree:this.bgBrightness})
  3426. .backgroundBlurStyle(this.isMusicCoverHomeBackgroundActive()
  3427. ? BlurStyle.BACKGROUND_ULTRA_THICK :
  3428. BlurStyle.NONE)
  3429. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  3430. }
  3431. @Builder
  3432. buildContentView() {
  3433. Column() {
  3434. if (this.isSearchMode) {
  3435. this.buildSearchContent()
  3436. } else if (this.isAlbumMode) {
  3437. FindAlbumDetail({
  3438. albumTitle: this.currentAlbumTitle,
  3439. albumArtist: this.currentAlbumArtist,
  3440. albumCoverPath: this.currentAlbumCoverPath,
  3441. albumSourceLabel: this.currentAlbumSourceLabel,
  3442. songs: this.currentAlbumSongs,
  3443. useTransparentBackground: this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive(),
  3444. defaultSortType: this.currentAlbumDefaultSortType,
  3445. supportRecentPlayedSort: this.currentAlbumSupportRecentPlayedSort,
  3446. allowDelete: this.canDeleteCurrentAlbumSongs(),
  3447. onBack: () => {
  3448. this.exitAlbumMode()
  3449. },
  3450. topSafeHeight: this.topSafeHeight,
  3451. bottomSafeHeight: this.bottomSafeHeight,
  3452. themeColor: this.themeColor,
  3453. onPlayAll: (songs?: VideoItem[]) => {
  3454. this.handleAlbumPlayAll(songs)
  3455. },
  3456. onRandomPlay: (songs?: VideoItem[]) => {
  3457. this.handleAlbumRandomPlay(songs)
  3458. },
  3459. onSongTap: (index: number, songs?: VideoItem[]) => {
  3460. this.handleAlbumSongTap(index, songs)
  3461. },
  3462. onPlayNextSong: (song: VideoItem) => {
  3463. this.handleAddSongToNextPlay(song)
  3464. },
  3465. onFavoriteSong: (song: VideoItem) => {
  3466. void this.handleAlbumFavoriteSong(song)
  3467. },
  3468. onDeleteSong: (song: VideoItem) => {
  3469. this.openDeleteSongDialog(song)
  3470. }
  3471. })
  3472. } else {
  3473. Refresh({ refreshing: $$this.isRefreshing }) {
  3474. Column() {
  3475. if (this.isPageLoading) {
  3476. this.buildLoadingState()
  3477. } else {
  3478. this.buildDiscoveryContent()
  3479. }
  3480. }
  3481. .width('100%')
  3482. .height('100%')
  3483. }
  3484. .width('100%')
  3485. .layoutWeight(1)
  3486. .pullDownRatio(this.refreshPullRatio)
  3487. .pullToRefresh(true)
  3488. .refreshOffset(0)
  3489. .onOffsetChange((offset: number) => {
  3490. this.refreshPullRatio = 1 - Math.pow((offset / this.maxRefreshingHeight), 3)
  3491. })
  3492. .onRefreshing(() => {
  3493. void this.loadDiscoveryContent(true)
  3494. })
  3495. }
  3496. }
  3497. .width('100%')
  3498. .height('100%')
  3499. .backgroundColor(this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive()
  3500. ? Color.Transparent : $r('app.color.start_window_background'))
  3501. }
  3502. }