FindView.ets 130 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790
  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. .objectFit(ImageFit.Cover)
  2984. Row() {
  2985. Text('心动歌单')
  2986. .fontColor($r('app.color.white'))
  2987. .fontSize(9)
  2988. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2989. .backgroundColor('#7A000000')
  2990. .borderRadius(999)
  2991. Text(`${playlist.songCount} 首`)
  2992. .fontColor($r('app.color.white'))
  2993. .fontSize(9)
  2994. .padding({ left: 6, right: 6, top: 3, bottom: 3 })
  2995. .backgroundColor('#5C000000')
  2996. .borderRadius(999)
  2997. }
  2998. .justifyContent(FlexAlign.SpaceBetween)
  2999. .padding({ left: 8, right: 8, bottom: 8 })
  3000. .width(160)
  3001. }
  3002. Text(playlist.name)
  3003. .lineHeight(16)
  3004. .maxLines(1)
  3005. .width(160)
  3006. .fontColor(this.getPrimaryTextColor())
  3007. .fontSize(14)
  3008. .fontWeight(FontWeight.Bold)
  3009. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3010. Text(this.getPlaylistSubtitle(playlist))
  3011. .fontColor(this.getSecondaryTextColor())
  3012. .fontSize(10)
  3013. .maxLines(1)
  3014. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3015. .lineHeight(11)
  3016. .width(160)
  3017. .textAlign(TextAlign.Start)
  3018. }
  3019. }
  3020. @Builder
  3021. private buildHeartPlaylistSection() {
  3022. ConfigTitle({
  3023. text: '心动歌单',
  3024. showAction: false
  3025. })
  3026. List({ space: 8 }) {
  3027. ForEach(this.heartPlaylists, (playlist: Playlist) => {
  3028. ListItem() {
  3029. PointLightContentButton({
  3030. pointColor: this.themeColor,
  3031. buttonRadius: 16,
  3032. builder: () => {
  3033. this.buildHeartPlaylistCardContent(playlist)
  3034. }
  3035. })
  3036. .onClick(() => {
  3037. void this.openPlaylistDetail(playlist)
  3038. })
  3039. }
  3040. }, getFindPlaylistKey)
  3041. }
  3042. .listDirection(Axis.Horizontal)
  3043. .scrollBar(BarState.Off)
  3044. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  3045. }
  3046. @Builder
  3047. private buildCloudMoodSection() {
  3048. ConfigTitle({
  3049. text: '云卷云舒',
  3050. onActionClick: () => {
  3051. this.refreshCloudMoodSongs()
  3052. }
  3053. })
  3054. if (this.cloudMoodSongs.length === 0) {
  3055. this.buildSectionEmptyState('网盘随机还没有内容', '网盘歌曲加载完成后,这里会随机展示歌曲', false)
  3056. } else {
  3057. List({ space: 8 }) {
  3058. ForEach(this.cloudMoodSongs, (item: VideoItem, index: number) => {
  3059. ListItem() {
  3060. PointLightContentButton({
  3061. pointColor: this.themeColor,
  3062. buttonRadius: 16,
  3063. builder: () => {
  3064. this.buildHotSongCardContent(item)
  3065. }
  3066. })
  3067. .onClick(() => {
  3068. this.handleCloudMoodSongTap(index)
  3069. })
  3070. }
  3071. .margin({
  3072. left: index === 0 ? 2 : 0,
  3073. right: index === this.cloudMoodSongs.length - 1 ? 2 : 0
  3074. })
  3075. }, getFindSongKey)
  3076. }
  3077. .listDirection(Axis.Horizontal)
  3078. .scrollBar(BarState.Off)
  3079. .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
  3080. }
  3081. }
  3082. @Builder
  3083. private buildRecentSongCardContent(item: VideoItem) {
  3084. Column({ space: 6 }) {
  3085. Stack({ alignContent: Alignment.Bottom }) {
  3086. Image(this.getSongCover(item))
  3087. .width('100%')
  3088. .aspectRatio(3 / 4)
  3089. .borderRadius(16)
  3090. .objectFit(ImageFit.Cover)
  3091. Row() {
  3092. Text(item.duration ? item.duration : '')
  3093. .fontColor($r('app.color.white'))
  3094. .fontSize(9)
  3095. .visibility(item.duration ? Visibility.Visible : Visibility.None)
  3096. }
  3097. .width('100%')
  3098. .padding({ left: 8, right: 8, bottom: 8 })
  3099. .justifyContent(FlexAlign.SpaceBetween)
  3100. }
  3101. Text(this.getSongTitle(item))
  3102. .fontSize(13)
  3103. .fontWeight(FontWeight.Bold)
  3104. .lineHeight(17)
  3105. .fontColor(this.getPrimaryTextColor())
  3106. .maxLines(1)
  3107. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3108. .width('100%')
  3109. Text(this.getRecentSongSubtitle(item))
  3110. .fontSize(10)
  3111. .lineHeight(13)
  3112. .fontColor(this.getSecondaryTextColor())
  3113. .maxLines(1)
  3114. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3115. .width('100%')
  3116. }
  3117. .width('100%')
  3118. }
  3119. @Builder
  3120. private buildRecentSection() {
  3121. ConfigTitle({
  3122. text: '最近播放',
  3123. actionText: '更多',
  3124. actionSymbol: $r('sys.symbol.chevron_right'),
  3125. indicatorCount: this.getRecentSectionPages().length,
  3126. indicatorIndex: this.getSafeSectionPageIndex(this.getRecentSectionPages().length, this.recentSectionPageIndex),
  3127. onActionClick: () => {
  3128. this.openLocalSpecialList('recent')
  3129. }
  3130. })
  3131. if (this.recentSongs.length === 0) {
  3132. this.buildSectionEmptyState('最近播放还是空的', '播放过的歌曲会自动出现在这里', false)
  3133. } else {
  3134. Swiper() {
  3135. ForEach(this.getRecentSectionPages(), (page: FindSongPage, pageIndex: number) => {
  3136. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3137. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3138. GridCol() {
  3139. PointLightContentButton({
  3140. pointColor: this.themeColor,
  3141. buttonRadius: 16,
  3142. builder: () => {
  3143. this.buildRecentSongCardContent(item)
  3144. }
  3145. })
  3146. .width('100%')
  3147. .onClick(() => {
  3148. this.handleRecentSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3149. })
  3150. }
  3151. }, getFindSongKey)
  3152. }
  3153. }, getFindSongPageKey)
  3154. }
  3155. .width('100%')
  3156. .indicator(false)
  3157. .autoPlay(false)
  3158. .displayCount(this.getSwiperDisplayCount())
  3159. .loop(false)
  3160. .onChange((index: number) => {
  3161. this.recentSectionPageIndex = index
  3162. })
  3163. }
  3164. }
  3165. @Builder
  3166. private buildPopularSongCardContent(item: VideoItem) {
  3167. Column({ space: 6 }) {
  3168. Stack({ alignContent: Alignment.Bottom }) {
  3169. Image(this.getSongCover(item))
  3170. .aspectRatio(0.8)
  3171. .objectFit(ImageFit.Cover)
  3172. .width('100%')
  3173. .borderRadius(16)
  3174. Row() {
  3175. Text(item.playCount ? item.playCount + ' 次' : '')
  3176. .fontColor($r('app.color.white'))
  3177. .fontSize(9)
  3178. .lineHeight(11)
  3179. .visibility(item.playCount ? Visibility.Visible : Visibility.None)
  3180. }
  3181. .width('100%')
  3182. .justifyContent(FlexAlign.SpaceBetween)
  3183. .padding({ left: 8, right: 8, bottom: 8 })
  3184. }
  3185. Text(this.getSongTitle(item))
  3186. .fontColor(this.getPrimaryTextColor())
  3187. .maxLines(1)
  3188. .fontSize(14)
  3189. .lineHeight(16)
  3190. .fontWeight(FontWeight.Bold)
  3191. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3192. .width('100%')
  3193. Text(this.getSongSubtitle(item))
  3194. .fontColor(this.getSecondaryTextColor())
  3195. .textAlign(TextAlign.Start)
  3196. .maxLines(1)
  3197. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3198. .fontSize(10)
  3199. .lineHeight(11)
  3200. .width('100%')
  3201. }
  3202. .width('100%')
  3203. }
  3204. @Builder
  3205. private buildPopularSection() {
  3206. ConfigTitle({
  3207. text: '热门歌曲',
  3208. indicatorCount: this.popularSectionPages.length,
  3209. indicatorIndex: this.getSafeSectionPageIndex(this.popularSectionPages.length, this.popularSectionPageIndex),
  3210. onActionClick: () => {
  3211. Logger.info(TAG, `FindView clickChange popularSongs current=${this.buildSongSelectionLog(this.popularSongs)}`)
  3212. this.refreshPopularSongs()
  3213. }
  3214. })
  3215. if (this.popularSongs.length === 0) {
  3216. this.buildSectionEmptyState('热门歌曲还没生成', '播放次数高的歌曲会优先展示在这里', false)
  3217. } else {
  3218. Swiper() {
  3219. ForEach(this.popularSectionPages, (page: FindSongPage, pageIndex: number) => {
  3220. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3221. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3222. GridCol() {
  3223. PointLightContentButton({
  3224. pointColor: this.themeColor,
  3225. buttonRadius: 16,
  3226. builder: () => {
  3227. this.buildPopularSongCardContent(item)
  3228. }
  3229. })
  3230. .width('100%')
  3231. .onClick(() => {
  3232. this.handlePopularSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3233. })
  3234. }
  3235. }, getFindSongKey)
  3236. }
  3237. }, getFindSongPageKey)
  3238. }
  3239. .width('100%')
  3240. .indicator(false)
  3241. .autoPlay(false)
  3242. .loop(false)
  3243. .displayCount(this.getSwiperDisplayCount())
  3244. .onChange((index: number) => {
  3245. this.popularSectionPageIndex = index
  3246. })
  3247. }
  3248. }
  3249. @Builder
  3250. private buildFavoriteSongCardContent(item: VideoItem) {
  3251. Column({ space: 6 }) {
  3252. Stack({ alignContent: Alignment.Bottom }) {
  3253. Image(this.getSongCover(item))
  3254. .width('100%')
  3255. .aspectRatio(3 / 4)
  3256. .borderRadius(16)
  3257. .objectFit(ImageFit.Cover)
  3258. Row() {
  3259. Text(item.duration ? item.duration : '')
  3260. .fontColor($r('app.color.white'))
  3261. .fontSize(9)
  3262. .visibility(item.duration ? Visibility.Visible : Visibility.None)
  3263. }
  3264. .width('100%')
  3265. .padding({ left: 8, right: 8, bottom: 8 })
  3266. .justifyContent(FlexAlign.SpaceBetween)
  3267. }
  3268. Text(this.getSongTitle(item))
  3269. .fontSize(13)
  3270. .fontWeight(FontWeight.Bold)
  3271. .lineHeight(17)
  3272. .fontColor(this.getPrimaryTextColor())
  3273. .maxLines(1)
  3274. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3275. .width('100%')
  3276. Text(this.getSongSubtitle(item))
  3277. .fontSize(10)
  3278. .lineHeight(13)
  3279. .fontColor(this.getSecondaryTextColor())
  3280. .maxLines(1)
  3281. .textOverflow({ overflow: TextOverflow.Ellipsis })
  3282. .width('100%')
  3283. }
  3284. .width('100%')
  3285. }
  3286. @Builder
  3287. private buildFavoriteSection() {
  3288. ConfigTitle({
  3289. text: '我的收藏',
  3290. actionText: '更多',
  3291. actionSymbol: $r('sys.symbol.chevron_right'),
  3292. indicatorCount: this.getFavoriteSectionPages().length,
  3293. indicatorIndex: this.getSafeSectionPageIndex(this.getFavoriteSectionPages().length, this.favoriteSectionPageIndex),
  3294. onActionClick: () => {
  3295. this.openLocalSpecialList('favorite')
  3296. }
  3297. })
  3298. if (this.favoriteSongs.length === 0) {
  3299. this.buildSectionEmptyState('我的收藏还是空的', '', false)
  3300. } else {
  3301. Swiper() {
  3302. ForEach(this.getFavoriteSectionPages(), (page: FindSongPage, pageIndex: number) => {
  3303. GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) {
  3304. ForEach(page.items, (item: VideoItem, itemIndex: number) => {
  3305. GridCol() {
  3306. PointLightContentButton({
  3307. pointColor: this.themeColor,
  3308. buttonRadius: 16,
  3309. builder: () => {
  3310. this.buildFavoriteSongCardContent(item)
  3311. }
  3312. })
  3313. .width('100%')
  3314. .onClick(() => {
  3315. this.handleFavoriteSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex)
  3316. })
  3317. }
  3318. }, getFindSongKey)
  3319. }
  3320. }, getFindSongPageKey)
  3321. }
  3322. .width('100%')
  3323. .indicator(false)
  3324. .autoPlay(false)
  3325. .displayCount(this.getSwiperDisplayCount())
  3326. .loop(false)
  3327. .onChange((index: number) => {
  3328. this.favoriteSectionPageIndex = index
  3329. })
  3330. }
  3331. }
  3332. @Builder
  3333. private buildSectionEmptyState(title: string, subtitle: string, useLargeCard: boolean) {
  3334. Column({ space: 8 }) {
  3335. SymbolGlyph($r('sys.symbol.music'))
  3336. .attributeModifier(new SymbolGlyphFancyModifier(useLargeCard ?70 :42, '', ''))
  3337. .opacity(0.72)
  3338. Text(title)
  3339. .fontSize(15)
  3340. .fontWeight(FontWeight.Medium)
  3341. .fontColor(this.getPrimaryTextColor())
  3342. Text(subtitle)
  3343. .fontSize(12)
  3344. .fontColor(this.getSecondaryTextColor())
  3345. .textAlign(TextAlign.Center)
  3346. }
  3347. .width('100%')
  3348. .padding({ top: useLargeCard ? 42 : 26, bottom: useLargeCard ? 42 : 26, left: 12, right: 12 })
  3349. .borderRadius(20)
  3350. .justifyContent(FlexAlign.Center)
  3351. .alignItems(HorizontalAlign.Center)
  3352. }
  3353. @Builder
  3354. private buildDiscoveryContent() {
  3355. Scroll() {
  3356. Column() {
  3357. this.buildTopErrorBanner()
  3358. this.buildSwiperSection()
  3359. Column({ space: 6 }) {
  3360. this.buildActionButtons()
  3361. this.buildLocalRandomSection()
  3362. if (this.favoriteSongsPool.length > 0 || this.favoriteSongs.length > 0) {
  3363. this.buildFavoriteSection()
  3364. }
  3365. if (this.heartPlaylists.length > 0) {
  3366. this.buildHeartPlaylistSection()
  3367. }
  3368. if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) {
  3369. this.buildCloudSection()
  3370. }
  3371. this.buildFeaturedAlbumSection()
  3372. if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) {
  3373. this.buildCloudMoodSection()
  3374. }
  3375. if (this.remoteSongsPool.length > 0 || this.cloudAlbumsPool.length > 0 || this.cloudAlbums.length > 0) {
  3376. this.buildCloudAlbumSection()
  3377. }
  3378. this.buildRecentSection()
  3379. this.buildPopularSection()
  3380. }
  3381. .width('100%')
  3382. .padding({
  3383. left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
  3384. right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
  3385. top: 16,
  3386. bottom: this.bottomSafeHeight + 96
  3387. })
  3388. .alignItems(HorizontalAlign.Start)
  3389. }
  3390. }
  3391. .scrollBar(BarState.Off)
  3392. .edgeEffect(EdgeEffect.Spring)
  3393. .width('100%')
  3394. .layoutWeight(1)
  3395. }
  3396. build() {
  3397. Stack() {
  3398. this.buildContentView();
  3399. // 顶部区域:标题栏在上,面包屑在下
  3400. Column() {
  3401. this.topTitleBar()
  3402. }
  3403. .backgroundColor(Color.Transparent)
  3404. .width('100%')
  3405. .visibility(this.isAlbumMode ? Visibility.None : (this.isShowTitleBar ? Visibility.Visible :
  3406. this.autoHideTitle ? Visibility.None : Visibility.Visible))
  3407. .animation({
  3408. duration: 500,
  3409. curve: Curve.Friction // 可选动画曲线
  3410. })
  3411. .position({ top: 0, left: 0 })
  3412. // .backgroundColor($r('app.color.start_window_background'))
  3413. }
  3414. .width('100%')
  3415. .height('100%')
  3416. .alignContent(Alignment.Bottom)
  3417. .backgroundImage(this.isMusicCoverHomeBackgroundActive()
  3418. ? this.customizeMusicBgPath
  3419. : (this.isCustomizeBg ? this.customizeBgPath : $r('app.color.start_window_background')))
  3420. .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'})
  3421. .backgroundImagePosition(Alignment.Center)
  3422. .backdropBlur(this.blurValue)
  3423. .backgroundBrightness({rate:this.isMusicCoverHomeBackgroundActive() || this.isCustomizeBg ? 0.1 : 0,
  3424. lightUpDegree:this.bgBrightness})
  3425. .backgroundBlurStyle(this.isMusicCoverHomeBackgroundActive()
  3426. ? BlurStyle.BACKGROUND_ULTRA_THICK :
  3427. BlurStyle.NONE)
  3428. .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM])
  3429. }
  3430. @Builder
  3431. buildContentView() {
  3432. Column() {
  3433. if (this.isSearchMode) {
  3434. this.buildSearchContent()
  3435. } else if (this.isAlbumMode) {
  3436. FindAlbumDetail({
  3437. albumTitle: this.currentAlbumTitle,
  3438. albumArtist: this.currentAlbumArtist,
  3439. albumCoverPath: this.currentAlbumCoverPath,
  3440. albumSourceLabel: this.currentAlbumSourceLabel,
  3441. songs: this.currentAlbumSongs,
  3442. useTransparentBackground: this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive(),
  3443. defaultSortType: this.currentAlbumDefaultSortType,
  3444. supportRecentPlayedSort: this.currentAlbumSupportRecentPlayedSort,
  3445. allowDelete: this.canDeleteCurrentAlbumSongs(),
  3446. onBack: () => {
  3447. this.exitAlbumMode()
  3448. },
  3449. topSafeHeight: this.topSafeHeight,
  3450. bottomSafeHeight: this.bottomSafeHeight,
  3451. themeColor: this.themeColor,
  3452. onPlayAll: (songs?: VideoItem[]) => {
  3453. this.handleAlbumPlayAll(songs)
  3454. },
  3455. onRandomPlay: (songs?: VideoItem[]) => {
  3456. this.handleAlbumRandomPlay(songs)
  3457. },
  3458. onSongTap: (index: number, songs?: VideoItem[]) => {
  3459. this.handleAlbumSongTap(index, songs)
  3460. },
  3461. onPlayNextSong: (song: VideoItem) => {
  3462. this.handleAddSongToNextPlay(song)
  3463. },
  3464. onFavoriteSong: (song: VideoItem) => {
  3465. void this.handleAlbumFavoriteSong(song)
  3466. },
  3467. onDeleteSong: (song: VideoItem) => {
  3468. this.openDeleteSongDialog(song)
  3469. }
  3470. })
  3471. } else {
  3472. Refresh({ refreshing: $$this.isRefreshing }) {
  3473. Column() {
  3474. if (this.isPageLoading) {
  3475. this.buildLoadingState()
  3476. } else {
  3477. this.buildDiscoveryContent()
  3478. }
  3479. }
  3480. .width('100%')
  3481. .height('100%')
  3482. }
  3483. .width('100%')
  3484. .layoutWeight(1)
  3485. .pullDownRatio(this.refreshPullRatio)
  3486. .pullToRefresh(true)
  3487. .refreshOffset(0)
  3488. .onOffsetChange((offset: number) => {
  3489. this.refreshPullRatio = 1 - Math.pow((offset / this.maxRefreshingHeight), 3)
  3490. })
  3491. .onRefreshing(() => {
  3492. void this.loadDiscoveryContent(true)
  3493. })
  3494. }
  3495. }
  3496. .width('100%')
  3497. .height('100%')
  3498. .backgroundColor(this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive()
  3499. ? Color.Transparent : $r('app.color.start_window_background'))
  3500. }
  3501. }