}
10 |
11 | type ForwardRefFn = (p: React.PropsWithChildren
& React.RefAttributes) => React.ReactNode | null
12 |
13 | // type UndefinedOrNever = undefined
14 | type Actions = {
15 | [U in T as U['action']]: 'data' extends keyof U ? U['data'] : undefined
16 | }
17 |
18 | type WarpPromiseValue = T extends ((...args: infer P) => Promise)
19 | ? ((...args: P) => Promise)
20 | : T extends ((...args: infer P2) => infer R2)
21 | ? ((...args: P2) => Promise)
22 | : Promise
23 |
24 | type WarpPromiseRecord> = {
25 | [K in keyof T]: WarpPromiseValue
26 | }
27 |
--------------------------------------------------------------------------------
/src/app/(tabs)/search/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { StackScreenWithSearchBar } from '@/constants/layout'
2 | import { colors } from '@/constants/tokens'
3 | import { defaultStyles } from '@/styles'
4 | import i18n, { nowLanguage } from '@/utils/i18n'
5 | import { Stack } from 'expo-router'
6 | import { View } from 'react-native'
7 | const PlaylistsScreenLayout = () => {
8 | const language = nowLanguage.useValue()
9 | return (
10 |
11 |
12 |
19 |
20 |
31 |
32 |
33 | )
34 | }
35 |
36 | export default PlaylistsScreenLayout
37 |
--------------------------------------------------------------------------------
/android/settings.gradle:
--------------------------------------------------------------------------------
1 | rootProject.name = 'CyMusic'
2 |
3 | dependencyResolutionManagement {
4 | versionCatalogs {
5 | reactAndroidLibs {
6 | from(files(new File(["node", "--print", "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), "../gradle/libs.versions.toml")))
7 | }
8 | }
9 | }
10 |
11 | apply from: new File(["node", "--print", "require.resolve('expo/package.json')"].execute(null, rootDir).text.trim(), "../scripts/autolinking.gradle");
12 | useExpoModules()
13 |
14 | apply from: new File(["node", "--print", "require.resolve('@react-native-community/cli-platform-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), "../native_modules.gradle");
15 | applyNativeModulesSettingsGradle(settings)
16 |
17 | include ':app'
18 | includeBuild(new File(["node", "--print", "require.resolve('@react-native/gradle-plugin/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim()).getParentFile())
19 |
--------------------------------------------------------------------------------
/src/styles/index.ts:
--------------------------------------------------------------------------------
1 | import { colors, fontSize } from '@/constants/tokens'
2 | import { StyleSheet } from 'react-native'
3 |
4 | export const defaultStyles = StyleSheet.create({
5 | container: {
6 | flex: 1,
7 | backgroundColor: colors.background,
8 | },
9 | text: {
10 | fontSize: fontSize.base,
11 | color: colors.text,
12 | },
13 | })
14 |
15 | export const utilsStyles = StyleSheet.create({
16 | centeredRow: {
17 | flexDirection: 'row',
18 | justifyContent: 'center',
19 | alignItems: 'center',
20 | },
21 | rightRow: {
22 | flexDirection: 'row',
23 | justifyContent: 'flex-end',
24 | alignItems: 'center',
25 | },
26 | slider: {
27 | height: 7,
28 | borderRadius: 16,
29 | },
30 | itemSeparator: {
31 | borderColor: colors.textMuted,
32 | borderWidth: StyleSheet.hairlineWidth,
33 | opacity: 0.3,
34 | },
35 | emptyContentText: {
36 | ...defaultStyles.text,
37 | color: colors.textMuted,
38 | textAlign: 'center',
39 | marginTop: 20,
40 | },
41 | emptyContentImage: {
42 | width: 200,
43 | height: 200,
44 | alignSelf: 'center',
45 | marginTop: 40,
46 | opacity: 0.3,
47 | },
48 | })
49 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/tx/tipSearch.js:
--------------------------------------------------------------------------------
1 | import { httpFetch } from '../../request'
2 |
3 | export default {
4 | // regExps: {
5 | // relWord: /RELWORD=(.+)/,
6 | // },
7 | requestObj: null,
8 | tipSearch(str) {
9 | this.cancelTipSearch()
10 | this.requestObj = httpFetch(`https://c.y.qq.com/splcloud/fcgi-bin/smartbox_new.fcg?is_xml=0&format=json&key=${encodeURIComponent(str)}&loginUin=0&hostUin=0&format=json&inCharset=utf8&outCharset=utf-8¬ice=0&platform=yqq&needNewCode=0`, {
11 | headers: {
12 | Referer: 'https://y.qq.com/portal/player.html',
13 | },
14 | })
15 | return this.requestObj.promise.then(({ statusCode, body }) => {
16 | if (statusCode != 200 || body.code != 0) return Promise.reject(new Error('请求失败'))
17 | return body.data
18 | })
19 | },
20 | handleResult(rawData) {
21 | return rawData.map(info => `${info.name} - ${info.singer}`)
22 | },
23 | cancelTipSearch() {
24 | if (this.requestObj && this.requestObj.cancelHttp) this.requestObj.cancelHttp()
25 | },
26 | async search(str) {
27 | return this.tipSearch(str).then(result => this.handleResult(result.song.itemlist))
28 | },
29 | }
30 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/utils.js:
--------------------------------------------------------------------------------
1 | import { stringMd5 } from 'react-native-quick-md5'
2 | import { decodeName } from '../common'
3 |
4 | /**
5 | * 获取音乐音质
6 | * @param {*} info
7 | * @param {*} type
8 | */
9 |
10 | export const QUALITYS = ['flac24bit', 'flac', 'wav', 'ape', '320k', '192k', '128k']
11 | export const getMusicType = (info, type) => {
12 | const list = global.lx.qualityList[info.source]
13 | if (!list) return '128k'
14 | if (!list.includes(type)) type = list[list.length - 1]
15 | const rangeType = QUALITYS.slice(QUALITYS.indexOf(type))
16 | for (const type of rangeType) {
17 | if (info._types[type]) return type
18 | }
19 | return '128k'
20 | }
21 |
22 | export const toMD5 = str => stringMd5(str)
23 |
24 |
25 | /**
26 | * 格式化歌手
27 | * @param singers 歌手数组
28 | * @param nameKey 歌手名键值
29 | * @param join 歌手分割字符
30 | */
31 | export const formatSingerName = (singers, nameKey = 'name', join = '、') => {
32 | if (Array.isArray(singers)) {
33 | const singer = []
34 | singers.forEach(item => {
35 | let name = item[nameKey]
36 | if (!name) return
37 | singer.push(name)
38 | })
39 | return decodeName(singer.join(join))
40 | }
41 | return decodeName(String(singers ?? ''))
42 | }
43 |
--------------------------------------------------------------------------------
/src/helpers/searchAll.ts:
--------------------------------------------------------------------------------
1 | // helpers/searchAll.ts
2 |
3 | import { searchArtist, searchMusic } from '@/helpers/userApi/xiaoqiu'
4 | import { Track } from 'react-native-track-player'
5 |
6 | const PAGE_SIZE = 20
7 |
8 | type SearchType = 'songs' | 'artists'
9 |
10 | const searchAll = async (
11 | searchText: string,
12 | page: number = 1,
13 | type: SearchType = 'songs',
14 | ): Promise<{ data: Track[]; hasMore: boolean }> => {
15 | console.log('search text+++', searchText, 'page:', page, 'type:', type)
16 |
17 | let result
18 | if (type === 'songs') {
19 | console.log('search song')
20 | result = await searchMusic(searchText, page, PAGE_SIZE)
21 | } else {
22 | console.log('search artist')
23 | result = await searchArtist(searchText, page)
24 | // console.log('search result', result)
25 | // Transform artist results to Track format
26 | result.data = result.data.map((artist) => ({
27 | id: artist.id,
28 | title: artist.name,
29 | artist: artist.name,
30 | artwork: artist.avatar,
31 | isArtist: true,
32 | })) as Track[]
33 | }
34 |
35 | const hasMore = result.data.length === PAGE_SIZE
36 |
37 | return {
38 | data: result.data as Track[],
39 | hasMore,
40 | }
41 | }
42 |
43 | export default searchAll
44 |
--------------------------------------------------------------------------------
/src/app/(modals)/playList.tsx:
--------------------------------------------------------------------------------
1 | import { NowPlayList } from '@/components/NowPlayList'
2 | import { colors, screenPadding } from '@/constants/tokens'
3 | import { usePlayList } from '@/store/playList'
4 | import { defaultStyles } from '@/styles'
5 | import { useHeaderHeight } from '@react-navigation/elements'
6 | import React from 'react'
7 | import { StyleSheet } from 'react-native'
8 | import { SafeAreaView } from 'react-native-safe-area-context'
9 | import { Track } from 'react-native-track-player'
10 |
11 | const PlayListScreen = () => {
12 | const headerHeight = useHeaderHeight()
13 | const tracks = usePlayList()
14 |
15 | return (
16 |
17 |
18 |
19 | )
20 | }
21 |
22 | const styles = StyleSheet.create({
23 | modalContainer: {
24 | flex: 1,
25 | paddingHorizontal: screenPadding.horizontal,
26 | backgroundColor: defaultStyles.container.backgroundColor, // 设置默认背景颜色
27 | },
28 | header: {
29 | fontSize: 28,
30 | fontWeight: 'bold',
31 | padding: 0,
32 | paddingBottom: 20,
33 | paddingTop: 0,
34 | color: colors.text,
35 | },
36 | })
37 |
38 | export default PlayListScreen
39 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/tx/api-ikun.js:
--------------------------------------------------------------------------------
1 | import { requestMsg } from '../../message'
2 | import { httpFetch } from '../../request'
3 | import { headers, timeout } from '../options'
4 | import { getMediaSource } from '@/helpers/userApi/xiaoqiu'
5 |
6 | const api_ikun = {
7 | getMusicUrl(songInfo, type) {
8 | console.log('ikun>????s')
9 | const requestObj = httpFetch(`http://110.42.111.49:1314/url/tx/${songInfo.id}/${type}`, {
10 | method: 'get',
11 | timeout,
12 | headers,
13 | family: 4,
14 | })
15 | requestObj.promise = requestObj.promise.then(async ({ body }) => {
16 | // console.log(body.data)
17 | if (!body.data||(typeof body.data === 'string' && body.data.includes('error') ))
18 | {
19 | const resp = await getMediaSource(songInfo, '128k')
20 | console.log('获取成功:' + resp)
21 | return Promise.resolve({ type, url: resp.url })
22 | }
23 | console.log('获取成功:' + body.data)
24 | return body.code === 0
25 | ? Promise.resolve({ type, url: body.data })
26 | : Promise.reject(new Error(requestMsg.fail))
27 | })
28 |
29 | return requestObj.promise
30 | },
31 | getPic(songInfo) {
32 | return {
33 | promise: Promise.resolve(
34 | `https://y.gtimg.cn/music/photo_new/T002R500x500M000${songInfo.albumId}.jpg`,
35 | ),
36 | }
37 | },
38 | }
39 |
40 | export default api_ikun
41 |
--------------------------------------------------------------------------------
/src/utils/mediaIndexMap.ts:
--------------------------------------------------------------------------------
1 | interface IIndexMap {
2 | getIndexMap: () => Record>;
3 | getIndex: (musicItem: ICommon.IMediaBase) => number;
4 | has: (mediaItem: ICommon.IMediaBase) => boolean;
5 | }
6 |
7 | export function createMediaIndexMap(
8 | mediaItems: ICommon.IMediaBase[],
9 | ): IIndexMap {
10 | const indexMap: Record> = {};
11 |
12 | mediaItems.forEach((item, index) => {
13 | // 映射中不存在
14 | if (!indexMap[item.platform]) {
15 | indexMap[item.platform] = {
16 | [item.id]: index,
17 | };
18 | } else {
19 | // 修改映射
20 | indexMap[item.platform][item.id] = index;
21 | }
22 | });
23 |
24 | function getIndexMap() {
25 | return indexMap;
26 | }
27 |
28 | function getIndex(mediaItem: ICommon.IMediaBase) {
29 | if (!mediaItem) {
30 | return -1;
31 | }
32 | return indexMap[mediaItem.platform]?.[mediaItem.id] ?? -1;
33 | }
34 |
35 | function has(mediaItem: ICommon.IMediaBase) {
36 | if (!mediaItem) {
37 | return false;
38 | }
39 |
40 | return indexMap[mediaItem.platform]?.[mediaItem.id] > -1;
41 | }
42 |
43 | return {
44 | getIndexMap,
45 | getIndex,
46 | has,
47 | };
48 | }
49 |
--------------------------------------------------------------------------------
/src/components/PlayerRepeatToggle.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { useTrackPlayerRepeatMode } from '@/hooks/useTrackPlayerRepeatMode'
3 | import { MaterialCommunityIcons } from '@expo/vector-icons'
4 | import { ComponentProps } from 'react'
5 | import { RepeatMode } from 'react-native-track-player'
6 | import { match } from 'ts-pattern'
7 | import myTrackPlayer, { MusicRepeatMode, repeatModeStore } from '@/helpers/trackPlayerIndex'
8 | import { GlobalState } from '@/utils/stateMapper'
9 |
10 | type IconProps = Omit, 'name'>
11 | type IconName = ComponentProps['name']
12 |
13 | // const repeatOrder = [RepeatMode.Off, RepeatMode.Track, RepeatMode.Queue] as const
14 |
15 | export const PlayerRepeatToggle = ({ ...iconProps }: IconProps) => {
16 |
17 |
18 | const toggleRepeatMode = () => {
19 | myTrackPlayer.toggleRepeatMode()
20 | }
21 |
22 | const icon = match(myTrackPlayer.getRepeatMode())
23 | .returnType()
24 | .with(MusicRepeatMode.SHUFFLE, () => 'shuffle')
25 | .with(MusicRepeatMode.SINGLE, () => 'repeat-once')
26 | .with(MusicRepeatMode.QUEUE, () => 'repeat')
27 | .otherwise(() => 'repeat-off')
28 |
29 | return (
30 |
36 | )
37 | }
38 |
--------------------------------------------------------------------------------
/src/utils/stateMapper.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react'
2 |
3 | export default class StateMapper {
4 | private getFun: () => T
5 | private cbs: Set = new Set([])
6 | constructor(getFun: () => T) {
7 | this.getFun = getFun
8 | }
9 |
10 | notify = () => {
11 | this.cbs.forEach((_) => _?.())
12 | }
13 |
14 | useMappedState = () => {
15 | const [_state, _setState] = useState(this.getFun)
16 | const updateState = () => {
17 | _setState(this.getFun())
18 | }
19 | useEffect(() => {
20 | this.cbs.add(updateState)
21 | return () => {
22 | this.cbs.delete(updateState)
23 | }
24 | }, [])
25 | return _state
26 | }
27 | }
28 |
29 | type UpdateFunc = (prev: T) => T
30 |
31 | export class GlobalState {
32 | private value: T
33 | private stateMapper: StateMapper
34 |
35 | constructor(initValue: T) {
36 | this.value = initValue
37 | this.stateMapper = new StateMapper(this.getValue)
38 | }
39 |
40 | public getValue = () => {
41 | return this.value
42 | }
43 |
44 | public useValue = () => {
45 | return this.stateMapper.useMappedState()
46 | }
47 |
48 | public setValue = (value: T | UpdateFunc) => {
49 | let newValue: T
50 | if (typeof value === 'function') {
51 | newValue = (value as UpdateFunc)(this.value)
52 | } else {
53 | newValue = value
54 | }
55 |
56 | this.value = newValue
57 | this.stateMapper.notify()
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/hooks/useLogTrackPlayerState.tsx:
--------------------------------------------------------------------------------
1 | import { Event, useTrackPlayerEvents } from 'react-native-track-player'
2 |
3 | const events = [
4 | Event.PlaybackState,
5 | Event.PlaybackError,
6 | Event.PlaybackQueueEnded,
7 | Event.PlaybackActiveTrackChanged,
8 | Event.PlaybackPlayWhenReadyChanged,
9 | Event.PlaybackTrackChanged,
10 | Event.PlaybackProgressUpdated,
11 | ]
12 |
13 | export const useLogTrackPlayerState = () => {
14 | useTrackPlayerEvents(events, async (event) => {
15 | if (event.type === Event.PlaybackError) {
16 | console.warn('An error occurred: ', event)
17 | }
18 |
19 | if (event.type === Event.PlaybackState) {
20 | console.log('Playback state: ', event.state)
21 | } else if (event.type === Event.PlaybackQueueEnded) {
22 | console.log(' PlaybackQueueEnded: ', event.track)
23 | } else if (event.type === Event.PlaybackPlayWhenReadyChanged) {
24 | console.log('Ready ?:', event.playWhenReady)
25 | }
26 | // else if (event.type === Event.PlaybackProgressUpdated) {
27 | // const currentPosition = event.position
28 | // const currentLyric =
29 | // LyricManager.getLyricState().lyricParser?.getPosition(currentPosition).lrc
30 | // console.log('PlaybackProgressUpdated: ', currentLyric)
31 | // LyricManager.setCurrentLyric(currentLyric || null)
32 | // // LyricManager.refreshLyric()
33 | // }
34 | else {
35 | // console.log('Track other type:', event.type)
36 | }
37 | })
38 | }
39 |
--------------------------------------------------------------------------------
/src/hooks/useSetupTrackPlayer.tsx:
--------------------------------------------------------------------------------
1 | import myTrackPlayer from '@/helpers/trackPlayerIndex'
2 | import { useEffect, useRef } from 'react'
3 | import TrackPlayer, { Capability, RatingType, RepeatMode } from 'react-native-track-player'
4 |
5 | const setupPlayer = async () => {
6 | await TrackPlayer.setupPlayer({})
7 |
8 | await TrackPlayer.updateOptions({
9 | ratingType: RatingType.Heart,
10 | capabilities: [
11 | Capability.Play,
12 | Capability.Pause,
13 | Capability.SkipToNext,
14 | Capability.SkipToPrevious,
15 | Capability.Stop,
16 | Capability.SeekTo,
17 | ],
18 | progressUpdateEventInterval: 1,
19 | })
20 |
21 | await TrackPlayer.setVolume(1) // 默认音量1
22 | await TrackPlayer.setRepeatMode(RepeatMode.Queue)
23 | }
24 |
25 | export const useSetupTrackPlayer = ({ onLoad }: { onLoad?: () => void }) => {
26 | //useSetupTrackPlayer 这个自定义 Hook 用于初始化音乐播放器,并确保它只初始化一次。
27 | const isInitialized = useRef(false) //是一个 React Hook,用于持有可变的对象,这些对象在组件的生命周期内保持不变。使用 useRef 创建一个引用 isInitialized,初始值为 false。它用于跟踪播放器是否已经初始化。
28 |
29 | useEffect(() => {
30 | //是一个 React Hook,用于在函数组件中执行副作用(如数据获取、订阅等)。
31 | if (isInitialized.current) return
32 |
33 | setupPlayer()
34 | .then(async () => {
35 | await myTrackPlayer.setupTrackPlayer()
36 | isInitialized.current = true
37 | onLoad?.()
38 | })
39 | .catch((error) => {
40 | isInitialized.current = false
41 | console.error(error)
42 | })
43 | }, [onLoad])
44 | }
45 |
--------------------------------------------------------------------------------
/.idea/inspectionProfiles/Project_Default.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
18 |
19 |
20 |
21 |
22 |
--------------------------------------------------------------------------------
/src/app/(tabs)/favorites/favoriteMusic.tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistTracksList } from '@/components/PlaylistTracksList'
2 | import { screenPadding } from '@/constants/tokens'
3 | import { Playlist } from '@/helpers/types'
4 | import { useFavorites } from '@/store/library'
5 | import { defaultStyles } from '@/styles'
6 | import i18n from '@/utils/i18n'
7 | import React, { useMemo } from 'react'
8 | import { ScrollView, View } from 'react-native'
9 | import { Track } from 'react-native-track-player'
10 | const FavoriteMusicScreen = () => {
11 | // const search = useNavigationSearch({
12 | // searchBarOptions: {
13 | // placeholder: 'Find in favorites',
14 | // },
15 | // })
16 |
17 | const { favorites } = useFavorites()
18 | const playListItem = {
19 | name: 'Favorites',
20 | id: 'favorites',
21 | tracks: [],
22 | title: i18n.t('appTab.favoritesSongs'),
23 | coverImg: 'https://y.qq.com/mediastyle/global/img/cover_like.png?max_age=2592000',
24 | description: i18n.t('appTab.favoritesSongs'),
25 | }
26 | const playLists = [playListItem]
27 | const filteredFavoritesTracks = useMemo(() => {
28 | // if (!search) return favorites as Track[]
29 |
30 | return favorites as Track[]
31 | }, [favorites])
32 |
33 | return (
34 |
35 |
39 |
40 |
41 |
42 | )
43 | }
44 | export default FavoriteMusicScreen
45 |
--------------------------------------------------------------------------------
/src/types/user_api.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace LX {
2 | namespace UserApi {
3 | type UserApiSourceInfoType = 'music'
4 | type UserApiSourceInfoActions = 'musicUrl' | 'lyric' | 'pic'
5 |
6 | interface UserApiSourceInfo {
7 | name: string
8 | type: UserApiSourceInfoType
9 | actions: UserApiSourceInfoActions[]
10 | qualitys: LX.Quality[]
11 | }
12 |
13 | type UserApiSources = Record
14 |
15 |
16 | interface UserApiInfo {
17 | id: string
18 | name: string
19 | description: string
20 | // script: string
21 | allowShowUpdateAlert: boolean
22 | author: string
23 | homepage: string
24 | version: string
25 | sources?: UserApiSources
26 | }
27 |
28 | interface UserApiStatus {
29 | status: boolean
30 | message?: string
31 | apiInfo?: UserApiInfo
32 | }
33 |
34 | interface UserApiUpdateInfo {
35 | name: string
36 | description: string
37 | log: string
38 | updateUrl?: string
39 | }
40 |
41 | interface UserApiRequestParams {
42 | requestKey: string
43 | data: any
44 | }
45 | interface UserApiRequestParams {
46 | requestKey: string
47 | data: any
48 | }
49 | type UserApiRequestCancelParams = string
50 | type UserApiSetApiParams = string
51 |
52 | interface UserApiSetAllowUpdateAlertParams {
53 | id: string
54 | enable: boolean
55 | }
56 |
57 | interface ImportUserApi {
58 | apiInfo: UserApiInfo
59 | apiList: UserApiInfo[]
60 | }
61 |
62 | }
63 | }
64 |
--------------------------------------------------------------------------------
/src/hooks/useNavigationSearch.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { nowLanguage } from '@/utils/i18n'
3 | import { useNavigation } from 'expo-router'
4 | import { debounce } from 'lodash'
5 | import { useCallback, useLayoutEffect, useState } from 'react'
6 | import { SearchBarProps } from 'react-native-screens'
7 |
8 | const defaultSearchOptions: SearchBarProps = {
9 | tintColor: colors.primary,
10 | hideWhenScrolling: false,
11 | }
12 |
13 | export const useNavigationSearch = ({
14 | searchBarOptions,
15 | onFocus,
16 | onBlur,
17 | onCancel,
18 | }: {
19 | searchBarOptions?: SearchBarProps
20 | onFocus?: () => void
21 | onBlur?: () => void
22 | onCancel?: () => void
23 | }) => {
24 | const [search, setSearch] = useState('')
25 |
26 | const navigation = useNavigation()
27 | const language = nowLanguage.useValue()
28 |
29 | const debouncedSetSearch = useCallback(
30 | debounce((text) => {
31 | setSearch(text)
32 | }, 400),
33 | [],
34 | )
35 |
36 | const handleOnChangeText: SearchBarProps['onChangeText'] = ({ nativeEvent: { text } }) => {
37 | debouncedSetSearch(text)
38 | }
39 |
40 | useLayoutEffect(() => {
41 | navigation.setOptions({
42 | headerSearchBarOptions: {
43 | ...defaultSearchOptions,
44 | ...searchBarOptions,
45 | onChangeText: handleOnChangeText,
46 | onFocus: onFocus,
47 | onBlur: onBlur,
48 | onCancelButtonPress: (e) => {
49 | onCancel?.()
50 | searchBarOptions?.onCancelButtonPress?.(e)
51 | },
52 | },
53 | })
54 | }, [navigation, searchBarOptions, onFocus, onBlur, onCancel])
55 |
56 | return search
57 | }
58 |
--------------------------------------------------------------------------------
/src/app/(tabs)/radio/index.tsx:
--------------------------------------------------------------------------------
1 | import { RadioList } from '@/components/RadioList'
2 | import { screenPadding } from '@/constants/tokens'
3 | import { playlistNameFilter } from '@/helpers/filter'
4 | import { Playlist } from '@/helpers/types'
5 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
6 | import { usePlaylists } from '@/store/library'
7 | import { defaultStyles } from '@/styles'
8 | import i18n from '@/utils/i18n'
9 | import { useRouter } from 'expo-router'
10 | import { useMemo } from 'react'
11 | import { ScrollView, View } from 'react-native'
12 | const RadiolistsScreen = () => {
13 | const router = useRouter()
14 |
15 | const search = useNavigationSearch({
16 | searchBarOptions: {
17 | placeholder: i18n.t('find.inRadio'),
18 | cancelButtonText: i18n.t('find.cancel'),
19 | },
20 | })
21 |
22 | const { playlists, setPlayList } = usePlaylists()
23 | const filteredPlayLists = useMemo(() => {
24 | if (!search) return playlists
25 | return playlists.filter(playlistNameFilter(search))
26 | }, [search, playlists])
27 |
28 | const handlePlaylistPress = (playlist: Playlist) => {
29 | router.push(`/(tabs)/radio/${playlist.title}`)
30 | }
31 |
32 | return (
33 |
34 |
40 |
45 |
46 |
47 | )
48 | }
49 |
50 | export default RadiolistsScreen
51 |
--------------------------------------------------------------------------------
/android/build.gradle:
--------------------------------------------------------------------------------
1 | // Top-level build file where you can add configuration options common to all sub-projects/modules.
2 |
3 | buildscript {
4 | ext {
5 | buildToolsVersion = findProperty('android.buildToolsVersion') ?: '34.0.0'
6 | minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '23')
7 | compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '34')
8 | targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
9 | kotlinVersion = findProperty('android.kotlinVersion') ?: '1.8.10'
10 |
11 | ndkVersion = "25.1.8937393"
12 | }
13 | repositories {
14 | google()
15 | mavenCentral()
16 | }
17 | dependencies {
18 | classpath('com.android.tools.build:gradle')
19 | classpath('com.facebook.react:react-native-gradle-plugin')
20 | }
21 | }
22 |
23 | apply plugin: "com.facebook.react.rootproject"
24 |
25 | allprojects {
26 | repositories {
27 | maven {
28 | // All of React Native (JS, Obj-C sources, Android binaries) is installed from npm
29 | url(new File(['node', '--print', "require.resolve('react-native/package.json')"].execute(null, rootDir).text.trim(), '../android'))
30 | }
31 | maven {
32 | // Android JSC is installed from npm
33 | url(new File(['node', '--print', "require.resolve('jsc-android/package.json', { paths: [require.resolve('react-native/package.json')] })"].execute(null, rootDir).text.trim(), '../dist'))
34 | }
35 |
36 | google()
37 | mavenCentral()
38 | maven { url 'https://www.jitpack.io' }
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/xm.js:
--------------------------------------------------------------------------------
1 | // import { apis } from '../api-source'
2 | // import leaderboard from './leaderboard'
3 | // import songList from './songList'
4 | // import musicSearch from './musicSearch'
5 | // import pic from './pic'
6 | // import lyric from './lyric'
7 | // import hotSearch from './hotSearch'
8 | // import comment from './comment'
9 | // import musicInfo from './musicInfo'
10 | // import { closeVerifyModal } from './util'
11 |
12 | const xm = {
13 | // songList,
14 | // musicSearch,
15 | // leaderboard,
16 | // hotSearch,
17 | // closeVerifyModal,
18 | comment: {
19 | getComment() {
20 | return Promise.reject(new Error('fail'))
21 | },
22 | getHotComment() {
23 | return Promise.reject(new Error('fail'))
24 | },
25 | },
26 | getMusicUrl(songInfo, type) {
27 | return {
28 | promise: Promise.reject(new Error('fail')),
29 | }
30 | // return apis('xm').getMusicUrl(songInfo, type)
31 | },
32 | getLyric(songInfo) {
33 | return {
34 | promise: Promise.reject(new Error('fail')),
35 | }
36 | // return lyric.getLyric(songInfo)
37 | },
38 | getPic(songInfo) {
39 | return Promise.reject(new Error('fail'))
40 | // return pic.getPic(songInfo)
41 | },
42 | // getMusicDetailPageUrl(songInfo) {
43 | // if (songInfo.songStringId) return `https://www.xiami.com/song/${songInfo.songStringId}`
44 |
45 | // musicInfo.getMusicInfo(songInfo).then(({ data }) => {
46 | // songInfo.songStringId = data.songStringId
47 | // })
48 | // return `https://www.xiami.com/song/${songInfo.songmid}`
49 | // },
50 | // init() {
51 | // getToken()
52 | // },
53 | }
54 |
55 | export default xm
56 |
--------------------------------------------------------------------------------
/src/components/MovingText.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react'
2 | import Animated, {
3 | Easing,
4 | StyleProps,
5 | cancelAnimation,
6 | useAnimatedStyle,
7 | useSharedValue,
8 | withDelay,
9 | withRepeat,
10 | withTiming,
11 | } from 'react-native-reanimated'
12 |
13 | export type MovingTextProps = {
14 | text: string
15 | animationThreshold: number
16 | style?: StyleProps
17 | }
18 |
19 | export const MovingText = ({ text, animationThreshold, style }: MovingTextProps) => {
20 | const translateX = useSharedValue(0)
21 | const shouldAnimate = text.length >= animationThreshold
22 |
23 | const textWidth = text.length * 3
24 |
25 | useEffect(() => {
26 | if (!shouldAnimate) return
27 |
28 | translateX.value = withDelay(
29 | 1000,
30 | withRepeat(
31 | withTiming(-textWidth, {
32 | duration: 5000,
33 | easing: Easing.linear,
34 | }),
35 | -1,
36 | true,
37 | ),
38 | )
39 |
40 | return () => {
41 | cancelAnimation(translateX)
42 | translateX.value = 0//useEffect 钩子函数的返回值是一个清理函数(cleanup function),它在以下情况下执行:组件卸载时。依赖项(dependency array)中的某个值在重新渲染时发生变化时。
43 | }
44 | }, [translateX, text, animationThreshold, shouldAnimate, textWidth])
45 |
46 | const animatedStyle = useAnimatedStyle(() => {
47 | return {
48 | transform: [{ translateX: translateX.value }],
49 | }
50 | })
51 |
52 | return (
53 |
64 | {text}
65 |
66 | )
67 | }
68 |
--------------------------------------------------------------------------------
/app.json:
--------------------------------------------------------------------------------
1 | {
2 | "expo": {
3 | "name": "CyMusic",
4 | "slug": "CyMusic",
5 | "version": "1.1.7",
6 | "orientation": "portrait",
7 | "icon": "./assets/1024.png",
8 | "userInterfaceStyle": "dark",
9 | "scheme": "cymusic",
10 | "splash": {
11 | "image": "./assets/splash.png",
12 | "resizeMode": "cover",
13 | "backgroundColor": "#000"
14 | },
15 | "assetBundlePatterns": ["**/*"],
16 | "ios": {
17 | "supportsTablet": true,
18 | "bundleIdentifier": "com.music.player.gyc",
19 | "usesIcloudStorage": false,
20 | "infoPlist": {
21 | "UIBackgroundModes": ["audio"],
22 | "UIFileSharingEnabled": true,
23 | "LSSupportsOpeningDocumentsInPlace": true,
24 | "NSAppTransportSecurity": {
25 | "NSAllowsArbitraryLoads": true
26 | },
27 | "CADisableMinimumFrameDurationOnPhone": true
28 | },
29 | "splash": {
30 | "image": "./assets/splash.png",
31 | "resizeMode": "cover",
32 | "backgroundColor": "#000"
33 | }
34 | },
35 | "android": {
36 | "adaptiveIcon": {
37 | "foregroundImage": "./assets/adaptive-icon.png",
38 | "backgroundColor": "#000"
39 | },
40 | "package": "com.music.player.gyc"
41 | },
42 | "web": {
43 | "favicon": "./assets/favicon.png"
44 | },
45 | "plugins": [
46 | "expo-router",
47 | "expo-localization",
48 | [
49 | "expo-share-intent",
50 | {
51 | "iosActivationRules": {
52 | "NSExtensionActivationSupportsFileWithMaxCount": 1,
53 | "NSExtensionActivationSupportsWebURLWithMaxCount": 1,
54 | "NSExtensionActivationSupportsText": 1
55 | }
56 | }
57 | ]
58 | ],
59 | "experiments": {
60 | "typedRoutes": true,
61 | "tsconfigPaths": true
62 | }
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/app/(tabs)/favorites/_layout.tsx:
--------------------------------------------------------------------------------
1 | import AddPlayListButton from '@/components/AddPlayListButton'
2 | import { StackScreenWithSearchBar } from '@/constants/layout'
3 | import { colors } from '@/constants/tokens'
4 | import { defaultStyles } from '@/styles'
5 | import i18n, { nowLanguage } from '@/utils/i18n'
6 | import { Stack } from 'expo-router'
7 | import { View } from 'react-native'
8 | const FavoritesScreenLayout = () => {
9 | const language = nowLanguage.useValue()
10 | return (
11 |
12 |
13 | ,
19 | }}
20 | />
21 |
32 |
43 |
54 |
55 |
56 | )
57 | }
58 |
59 | export default FavoritesScreenLayout
60 |
--------------------------------------------------------------------------------
/src/types/sync.d.ts:
--------------------------------------------------------------------------------
1 | declare global {
2 | namespace LX {
3 | namespace Sync {
4 | interface Status {
5 | status: boolean
6 | message: string
7 | }
8 |
9 | interface KeyInfo {
10 | clientId: string
11 | key: string
12 | serverName: string
13 | }
14 |
15 | interface Socket extends WebSocket {
16 | isReady: boolean
17 | data: {
18 | keyInfo: KeyInfo
19 | urlInfo: UrlInfo
20 | }
21 | moduleReadys: {
22 | list: boolean
23 | dislike: boolean
24 | }
25 |
26 | onClose: (handler: (err: Error) => (void | Promise)) => () => void
27 |
28 | remote: LX.Sync.ServerSyncActions
29 | remoteQueueList: LX.Sync.ServerSyncListActions
30 | remoteQueueDislike: LX.Sync.ServerSyncDislikeActions
31 | }
32 |
33 |
34 | interface ModeTypes {
35 | list: LX.Sync.List.SyncMode
36 | dislike: LX.Sync.Dislike.SyncMode
37 | }
38 |
39 | type ModeType = { [K in keyof ModeTypes]: { type: K, mode: ModeTypes[K] } }[keyof ModeTypes]
40 |
41 | interface UrlInfo {
42 | wsProtocol: string
43 | httpProtocol: string
44 | hostPath: string
45 | href: string
46 | }
47 |
48 | interface ListConfig {
49 | skipSnapshot: boolean
50 | }
51 | interface DislikeConfig {
52 | skipSnapshot: boolean
53 | }
54 | type ServerType = 'desktop-app' | 'server'
55 | interface EnabledFeatures {
56 | list?: false | ListConfig
57 | dislike?: false | DislikeConfig
58 | }
59 | type SupportedFeatures = Partial<{ [k in keyof EnabledFeatures]: number }>
60 | }
61 | }
62 | }
63 |
64 | export {}
65 |
--------------------------------------------------------------------------------
/src/types/list_sync.d.ts:
--------------------------------------------------------------------------------
1 | declare namespace LX {
2 |
3 | namespace Sync {
4 | namespace List {
5 | interface ListInfo {
6 | lastSyncDate?: number
7 | snapshotKey: string
8 | }
9 |
10 | interface SyncActionBase {
11 | action: A
12 | }
13 | interface SyncActionData extends SyncActionBase {
14 | data: D
15 | }
16 | type SyncAction = D extends undefined ? SyncActionBase : SyncActionData
17 | type ActionList = SyncAction<'list_data_overwrite', LX.List.ListActionDataOverwrite>
18 | | SyncAction<'list_create', LX.List.ListActionAdd>
19 | | SyncAction<'list_remove', LX.List.ListActionRemove>
20 | | SyncAction<'list_update', LX.List.ListActionUpdate>
21 | | SyncAction<'list_update_position', LX.List.ListActionUpdatePosition>
22 | | SyncAction<'list_music_add', LX.List.ListActionMusicAdd>
23 | | SyncAction<'list_music_move', LX.List.ListActionMusicMove>
24 | | SyncAction<'list_music_remove', LX.List.ListActionMusicRemove>
25 | | SyncAction<'list_music_update', LX.List.ListActionMusicUpdate>
26 | | SyncAction<'list_music_update_position', LX.List.ListActionMusicUpdatePosition>
27 | | SyncAction<'list_music_overwrite', LX.List.ListActionMusicOverwrite>
28 | | SyncAction<'list_music_clear', LX.List.ListActionMusicClear>
29 |
30 | type ListData = Omit
31 | type SyncMode = 'merge_local_remote'
32 | | 'merge_remote_local'
33 | | 'overwrite_local_remote'
34 | | 'overwrite_remote_local'
35 | | 'overwrite_local_remote_full'
36 | | 'overwrite_remote_local_full'
37 | // | 'none'
38 | | 'cancel'
39 | }
40 |
41 |
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/src/helpers/logger.ts:
--------------------------------------------------------------------------------
1 | import { createStore, useStore } from 'zustand'
2 |
3 | type LogLevel = 'INFO' | 'WARN' | 'ERROR'
4 |
5 | type LogEntry = {
6 | timestamp: string
7 | level: LogLevel
8 | message: string
9 | details?: any[]
10 | }
11 |
12 | interface LoggerState {
13 | logs: LogEntry[]
14 | addLog: (level: LogLevel, ...args: any[]) => void
15 | clearLogs: () => void
16 | }
17 |
18 | const stringifyArg = (arg: any): string => {
19 | if (typeof arg === 'string') return arg
20 | try {
21 | return JSON.stringify(arg, null, 2)
22 | } catch (error) {
23 | return `[Unstringifiable Object]: ${Object.prototype.toString.call(arg)}`
24 | }
25 | }
26 |
27 | export const useLogger = createStore((set) => ({
28 | logs: [],
29 | addLog: (level, ...args) =>
30 | set((state) => {
31 | const message = args.map(stringifyArg).join(' ')
32 | const newLog = {
33 | timestamp: new Date().toISOString(),
34 | level,
35 | message,
36 | details: args.length > 1 ? args : undefined,
37 | }
38 |
39 | // 在控制台打印日志
40 | console.log(`[${newLog.timestamp}] [${level}] ${message}`)
41 |
42 | return {
43 | logs: [
44 | ...state.logs,
45 | {
46 | timestamp: new Date().toISOString(),
47 | level,
48 | message,
49 | details: args.length > 1 ? args : undefined,
50 | },
51 | ],
52 | }
53 | }),
54 | clearLogs: () => set({ logs: [] }),
55 | }))
56 |
57 | const createLogFunction =
58 | (level: LogLevel) =>
59 | (...args: any[]) => {
60 | useLogger.getState().addLog(level, ...args)
61 | }
62 |
63 | export const logInfo = createLogFunction('INFO')
64 | export const logWarn = createLogFunction('WARN')
65 | export const logError = createLogFunction('ERROR')
66 |
67 | export const useLoggerHook = () => useStore(useLogger)
68 |
--------------------------------------------------------------------------------
/src/app/(tabs)/favorites/[name].tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistTracksList } from '@/components/PlaylistTracksList'
2 | import { screenPadding } from '@/constants/tokens'
3 | import myTrackPlayer, { playListsStore } from '@/helpers/trackPlayerIndex'
4 | import { Playlist } from '@/helpers/types'
5 |
6 | import { defaultStyles } from '@/styles'
7 | import { Redirect, useLocalSearchParams } from 'expo-router'
8 | import React, { useCallback, useMemo } from 'react'
9 | import { ScrollView, View } from 'react-native'
10 | import { Track } from 'react-native-track-player'
11 |
12 | const PlaylistScreen = () => {
13 | const { name: playlistID } = useLocalSearchParams<{ name: string }>()
14 | const playlists = playListsStore.useValue() as Playlist[] | null
15 |
16 | const playlist = useMemo(() => {
17 | return playlists?.find((p) => p.id === playlistID)
18 | }, [playlistID, playlists])
19 |
20 | const songs = useMemo(() => {
21 | return playlist?.songs || []
22 | }, [playlist])
23 |
24 | const handleDeleteTrack = useCallback(
25 | (trackId: string) => {
26 | myTrackPlayer.deleteSongFromStoredPlayList(playlist as Playlist, trackId)
27 | },
28 | [playlistID],
29 | )
30 |
31 | if (!playlist) {
32 | console.warn(`Playlist ${playlistID} was not found!`)
33 | return
34 | }
35 |
36 | return (
37 |
38 |
42 |
48 |
49 |
50 | )
51 | }
52 |
53 | export default PlaylistScreen
54 |
--------------------------------------------------------------------------------
/src/components/RadioListItem.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { Playlist } from '@/helpers/types'
3 | import { defaultStyles } from '@/styles'
4 | import { AntDesign } from '@expo/vector-icons'
5 | import { StyleSheet, Text, TouchableHighlight, TouchableHighlightProps, View } from 'react-native'
6 | import FastImage from 'react-native-fast-image'
7 |
8 | type PlaylistListItemProps = {
9 | playlist: Playlist
10 | } & TouchableHighlightProps
11 |
12 | export const RadioListItem = ({ playlist, ...props }: PlaylistListItemProps) => {
13 | return (
14 |
15 |
16 |
17 |
24 |
25 |
26 |
34 |
35 | {playlist.title}
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const styles = StyleSheet.create({
46 | playlistItemContainer: {
47 | flexDirection: 'row',
48 | columnGap: 14,
49 | alignItems: 'center',
50 | paddingRight: 90,
51 | },
52 | playlistArtworkImage: {
53 | borderRadius: 8,
54 | width: 70,
55 | height: 70,
56 | },
57 | playlistNameText: {
58 | ...defaultStyles.text,
59 | fontSize: 17,
60 | fontWeight: '600',
61 | maxWidth: '80%',
62 | },
63 | })
64 |
--------------------------------------------------------------------------------
/ios/ShareExtension/MainInterface.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
--------------------------------------------------------------------------------
/src/types/download_list.d.ts:
--------------------------------------------------------------------------------
1 |
2 | // interface DownloadList {
3 |
4 | // }
5 |
6 |
7 | declare namespace LX {
8 | namespace Download {
9 | type DownloadTaskStatus = 'run'
10 | | 'waiting'
11 | | 'pause'
12 | | 'error'
13 | | 'completed'
14 |
15 | type FileExt = 'mp3' | 'flac' | 'wav' | 'ape'
16 |
17 | interface ProgressInfo {
18 | progress: number
19 | speed: string
20 | downloaded: number
21 | total: number
22 | }
23 |
24 | interface DownloadTaskActionBase {
25 | action: A
26 | }
27 | interface DownloadTaskActionData extends DownloadTaskActionBase {
28 | data: D
29 | }
30 | type DownloadTaskAction = D extends undefined ? DownloadTaskActionBase : DownloadTaskActionData
31 |
32 | type DownloadTaskActions = DownloadTaskAction<'start'>
33 | | DownloadTaskAction<'complete'>
34 | | DownloadTaskAction<'refreshUrl'>
35 | | DownloadTaskAction<'statusText', string>
36 | | DownloadTaskAction<'progress', ProgressInfo>
37 | | DownloadTaskAction<'error', {
38 | error?: string
39 | message?: string
40 | }>
41 |
42 | interface ListItem {
43 | id: string
44 | isComplate: boolean
45 | status: DownloadTaskStatus
46 | statusText: string
47 | downloaded: number
48 | total: number
49 | progress: number
50 | speed: string
51 | metadata: {
52 | musicInfo: LX.Music.MusicInfoOnline
53 | url: string | null
54 | quality: LX.Quality
55 | ext: FileExt
56 | fileName: string
57 | filePath: string
58 | }
59 | }
60 |
61 | interface saveDownloadMusicInfo {
62 | list: ListItem[]
63 | addMusicLocationType: LX.AddMusicLocationType
64 | }
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/tx/hotSearch.js:
--------------------------------------------------------------------------------
1 | import { httpFetch } from '../../request'
2 |
3 | export default {
4 | _requestObj: null,
5 | async getList(retryNum = 0) {
6 | if (this._requestObj) this._requestObj.cancelHttp()
7 | if (retryNum > 2) return Promise.reject(new Error('try max num'))
8 |
9 | // const _requestObj = httpFetch('https://c.y.qq.com/splcloud/fcgi-bin/gethotkey.fcg', {
10 | // method: 'get',
11 | // headers: {
12 | // Referer: 'https://y.qq.com/portal/player.html',
13 | // },
14 | // })
15 | const _requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
16 | method: 'post',
17 | body: {
18 | comm: {
19 | ct: '19',
20 | cv: '1803',
21 | guid: '0',
22 | patch: '118',
23 | psrf_access_token_expiresAt: 0,
24 | psrf_qqaccess_token: '',
25 | psrf_qqopenid: '',
26 | psrf_qqunionid: '',
27 | tmeAppID: 'qqmusic',
28 | tmeLoginType: 0,
29 | uin: '0',
30 | wid: '0',
31 | },
32 | hotkey: {
33 | method: 'GetHotkeyForQQMusicPC',
34 | module: 'tencent_musicsoso_hotkey.HotkeyService',
35 | param: {
36 | search_id: '',
37 | uin: 0,
38 | },
39 | },
40 | },
41 | headers: {
42 | Referer: 'https://y.qq.com/portal/player.html',
43 | },
44 | })
45 | const { body, statusCode } = await _requestObj.promise
46 | // console.log(body)
47 | if (statusCode != 200 || body.code !== 0) throw new Error('获取热搜词失败')
48 | // console.log(body)
49 | return { source: 'tx', list: this.filterList(body.hotkey.data.vec_hotkey) }
50 | },
51 | filterList(rawList) {
52 | return rawList.map(item => item.query)
53 | },
54 | }
55 |
--------------------------------------------------------------------------------
/src/components/PlaylistListItem.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { Playlist } from '@/helpers/types'
3 | import { defaultStyles } from '@/styles'
4 | import { AntDesign } from '@expo/vector-icons'
5 | import { StyleSheet, Text, TouchableHighlight, TouchableHighlightProps, View } from 'react-native'
6 | import FastImage from 'react-native-fast-image'
7 |
8 | type PlaylistListItemProps = {
9 | playlist: Playlist
10 | } & TouchableHighlightProps
11 |
12 | export const PlaylistListItem = ({ playlist, ...props }: PlaylistListItemProps) => {
13 | return (
14 |
15 |
16 |
17 |
24 |
25 |
26 |
34 |
35 | {playlist.title}
36 |
37 |
38 |
39 |
40 |
41 |
42 | )
43 | }
44 |
45 | const styles = StyleSheet.create({
46 | playlistItemContainer: {
47 | flexDirection: 'row',
48 | columnGap: 14,
49 | alignItems: 'center',
50 | paddingRight: 90,
51 | },
52 | playlistArtworkImage: {
53 | borderRadius: 8,
54 | width: 70,
55 | height: 70,
56 | },
57 | playlistNameText: {
58 | ...defaultStyles.text,
59 | fontSize: 17,
60 | fontWeight: '600',
61 | maxWidth: '80%',
62 | },
63 | })
64 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/api-source.js:
--------------------------------------------------------------------------------
1 | import apiSourceInfo from './api-source-info'
2 |
3 | // import temp_api_kw from './kw/api-temp'
4 | // import test_api_kg from './kg/api-test'
5 | // import test_api_kw from './kw/api-test'
6 | // import test_api_tx from './tx/api-test'
7 | // import test_api_wy from './wy/api-test'
8 | // import test_api_mg from './mg/api-test'
9 |
10 | // import direct_api_kg from './kg/api-direct'
11 | // import direct_api_kw from './kw/api-direct'
12 | // import direct_api_tx from './tx/api-direct'
13 | // import direct_api_wy from './wy/api-direct'
14 | // import direct_api_mg from './mg/api-direct'
15 |
16 |
17 |
18 | const apiList = {
19 | // temp_api_kw,
20 | // // test_api_bd: require('./bd/api-test'),
21 | // test_api_kg,
22 | // test_api_kw,
23 | // test_api_tx,
24 | // test_api_wy,
25 | // test_api_mg,
26 | // direct_api_kg,
27 | // direct_api_kw,
28 | // direct_api_tx,
29 | // direct_api_wy,
30 | // direct_api_mg,
31 | // test_api_tx: require('./tx/api-test'),
32 | // test_api_wy: require('./wy/api-test'),
33 | // test_api_xm: require('./xm/api-test'),
34 | }
35 | const supportQuality = {}
36 |
37 | for (const api of apiSourceInfo) {
38 | supportQuality[api.id] = api.supportQualitys
39 | // for (const source of Object.keys(api.supportQualitys)) {
40 | // const path = `./${source}/api-${api.id}`
41 | // console.log(path)
42 | // apiList[`${api.id}_api_${source}`] = path
43 | // }
44 | }
45 |
46 | // const getAPI = source => apiList[`${settingState.setting['common.apiSource']}_api_${source}`]
47 |
48 | const apis = source => {
49 | // if (/^user_api/.test(settingState.setting['common.apiSource'])) return global.lx.apis[source]
50 | // const api = getAPI(source)
51 | // if (api) return api
52 | // throw new Error('Api is not found')
53 | return global.lx.apis[source]
54 | }
55 |
56 | export { apis, supportQuality }
57 |
--------------------------------------------------------------------------------
/src/components/RadioList.tsx:
--------------------------------------------------------------------------------
1 | import { RadioListItem } from '@/components/RadioListItem'
2 | import { unknownTrackImageUri } from '@/constants/images'
3 | import { Playlist } from '@/helpers/types'
4 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
5 | import { utilsStyles } from '@/styles'
6 | import i18n from '@/utils/i18n'
7 | import { useMemo } from 'react'
8 | import { FlatList, FlatListProps, Text, View } from 'react-native'
9 | import FastImage from 'react-native-fast-image'
10 | type PlaylistsListProps = {
11 | playlists: Playlist[]
12 | onPlaylistPress: (playlist: Playlist) => void
13 | } & Partial>
14 |
15 | const ItemDivider = () => (
16 |
17 | )
18 |
19 | export const RadioList = ({
20 | playlists,
21 | onPlaylistPress: handlePlaylistPress,
22 | ...flatListProps
23 | }: PlaylistsListProps) => {
24 | const search = useNavigationSearch({
25 | searchBarOptions: {
26 | placeholder: i18n.t('find.inPlaylist'),
27 | cancelButtonText: i18n.t('find.cancel'),
28 | },
29 | })
30 |
31 | const filteredPlaylist = useMemo(() => {
32 | return playlists
33 | }, [playlists, search])
34 |
35 | return (
36 |
42 | No playlist found
43 |
44 |
48 |
49 | }
50 | data={playlists}
51 | renderItem={({ item: playlist }) => (
52 | handlePlaylistPress(playlist)} />
53 | )}
54 | {...flatListProps}
55 | />
56 | )
57 | }
58 |
--------------------------------------------------------------------------------
/android/app/src/main/res/drawable/rn_edit_text_material.xml:
--------------------------------------------------------------------------------
1 |
2 |
16 |
21 |
22 |
23 |
32 |
33 |
34 |
35 |
36 |
37 |
--------------------------------------------------------------------------------
/src/types/player.d.ts:
--------------------------------------------------------------------------------
1 | import type { Track as RNTrack } from 'react-native-track-player'
2 |
3 | declare global {
4 | namespace LX {
5 | namespace Player {
6 | interface MusicInfo {
7 | id: string | null
8 | pic: string | null | undefined
9 | lrc: string | null
10 | tlrc: string | null
11 | rlrc: string | null
12 | lxlrc: string | null
13 | rawlrc: string | null
14 | // url: string | null
15 | name: string
16 | singer: string
17 | album: string
18 | }
19 |
20 | interface LyricInfo extends LX.Music.LyricInfo {
21 | rawlrcInfo: LX.Music.LyricInfo
22 | }
23 |
24 | type PlayMusic = LX.Music.MusicInfo | LX.Download.ListItem
25 |
26 | type PlayMusicInfo = Readonly<{
27 | /**
28 | * 当前播放歌曲的列表 id
29 | */
30 | musicInfo: PlayMusic
31 | /**
32 | * 当前播放歌曲的列表 id
33 | */
34 | listId: string
35 | /**
36 | * 是否属于 “稍后播放”
37 | */
38 | isTempPlay: boolean
39 | }>
40 |
41 | interface PlayInfo {
42 | /**
43 | * 当前正在播放歌曲 index
44 | */
45 | playIndex: number
46 | /**
47 | * 播放器的播放列表 id
48 | */
49 | playerListId: string | null
50 | /**
51 | * 播放器播放歌曲 index
52 | */
53 | playerPlayIndex: number
54 | }
55 |
56 | interface TempPlayListItem {
57 | /**
58 | * 播放列表id
59 | */
60 | listId: string
61 | /**
62 | * 歌曲信息
63 | */
64 | musicInfo: PlayMusic
65 | /**
66 | * 是否添加到列表顶部
67 | */
68 | isTop?: boolean
69 | }
70 |
71 | interface SavedPlayInfo {
72 | time: number
73 | maxTime: number
74 | listId: string
75 | index: number
76 | }
77 |
78 | interface Track extends RNTrack {
79 | musicId: string
80 | // original: PlayMusic
81 | // quality: LX.Quality
82 | }
83 |
84 | }
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/helpers/userApiHelper.ts:
--------------------------------------------------------------------------------
1 | import { getData } from 'ajv/lib/compile/validate'
2 | import { saveDataMultiple } from '@/helpers/storage'
3 | import { storageDataPrefix } from '@/constants/constant'
4 |
5 | const INFO_NAMES = {
6 | name: 24,
7 | description: 36,
8 | author: 56,
9 | homepage: 1024,
10 | version: 36,
11 | } as const
12 | type INFO_NAMES_Type = typeof INFO_NAMES
13 |
14 | const userApis: LX.UserApi.UserApiInfo[] = []
15 | const userApiPrefix = storageDataPrefix.userApi
16 |
17 |
18 | export const addUserApi = async(script: string): Promise => {
19 | const result = /^\/\*[\S|\s]+?\*\//.exec(script);
20 | if (!result) throw new Error('user_api_add_failed_tip');
21 |
22 | const scriptInfo = matchInfo(result[0]);
23 |
24 | scriptInfo.name ||= `user_api_${new Date().toLocaleString()}`;
25 | const apiInfo = {
26 | id: `user_api_${Math.random().toString().substring(2, 5)}_${Date.now()}`,
27 | ...scriptInfo,
28 | script,
29 | allowShowUpdateAlert: true,
30 | };
31 | userApis.length = 0;
32 |
33 | userApis.push(apiInfo);
34 | await saveDataMultiple([
35 | [userApiPrefix, userApis],
36 | [`${userApiPrefix}${apiInfo.id}`, script],
37 | ]);
38 | return apiInfo;
39 | };
40 |
41 | const matchInfo = (scriptInfo: string) => {
42 | const infoArr = scriptInfo.split(/\r?\n/)
43 | const rxp = /^\s?\*\s?@(\w+)\s(.+)$/
44 | const infos: Partial> = {}
45 | for (const info of infoArr) {
46 | const result = rxp.exec(info)
47 | if (!result) continue
48 | const key = result[1] as keyof typeof INFO_NAMES
49 | if (INFO_NAMES[key] == null) continue
50 | infos[key] = result[2].trim()
51 | }
52 |
53 | for (const [key, len] of Object.entries(INFO_NAMES) as Array<{ [K in keyof INFO_NAMES_Type]: [K, INFO_NAMES_Type[K]] }[keyof INFO_NAMES_Type]>) {
54 | infos[key] ||= ''
55 | if (infos[key] == null) infos[key] = ''
56 | else if (infos[key]!.length > len) infos[key] = infos[key]!.substring(0, len) + '...'
57 | }
58 |
59 | return infos as Record
60 | }
--------------------------------------------------------------------------------
/src/types/app.d.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-var */
2 | import type { AppEventTypes } from '@/event/appEvent'
3 | import type { ListEventTypes } from '@/event/listEvent'
4 | import type { DislikeEventTypes } from '@/event/dislikeEvent'
5 | import type { StateEventTypes } from '@/event/stateEvent'
6 | import type { I18n } from '@/lang/i18n'
7 | import type { Buffer as _Buffer } from 'buffer'
8 | import type { SettingScreenIds } from '@/screens/Home/Views/Setting'
9 |
10 | // interface Process {
11 | // env: {
12 | // NODE_ENV: 'development' | 'production'
13 | // }
14 | // versions: {
15 | // app: string
16 | // }
17 | // }
18 | interface GlobalData {
19 | fontSize: number
20 | gettingUrlId: string
21 |
22 | // event_app: AppType
23 | // event_list: ListType
24 |
25 | playerStatus: {
26 | isInitialized: boolean
27 | isRegisteredService: boolean
28 | isIniting: boolean
29 | }
30 | restorePlayInfo: LX.Player.SavedPlayInfo | null
31 | isScreenKeepAwake: boolean
32 | isPlayedStop: boolean
33 | isEnableSyncLog: boolean
34 | isEnableUserApiLog: boolean
35 | playerTrackId: string
36 |
37 | qualityList: LX.QualityList
38 | apis: Partial
39 | apiInitPromise: [Promise, boolean, (success: boolean) => void]
40 |
41 | jumpMyListPosition: boolean
42 |
43 | settingActiveId: SettingScreenIds
44 |
45 | /**
46 | * 首页是否正在滚动中,用于防止意外误触播放歌曲
47 | */
48 | homePagerIdle: boolean
49 |
50 | // windowInfo: {
51 | // screenW: number
52 | // screenH: number
53 | // fontScale: number
54 | // pixelRatio: number
55 | // screenPxW: number
56 | // screenPxH: number
57 | // }
58 |
59 | // syncKeyInfo: LX.Sync.KeyInfo
60 | }
61 |
62 |
63 | declare global {
64 | var isDev: boolean
65 | var lx: GlobalData
66 | var i18n: I18n
67 | var app_event: AppEventTypes
68 | var list_event: ListEventTypes
69 | var dislike_event: DislikeEventTypes
70 | var state_event: StateEventTypes
71 |
72 | var Buffer: typeof _Buffer
73 |
74 | module NodeJS {
75 | interface ProcessVersions {
76 | app: string
77 | }
78 | }
79 | // var process: Process
80 | }
81 |
--------------------------------------------------------------------------------
/src/app/(tabs)/radio/[name].tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistTracksList } from '@/components/PlaylistTracksList'
2 | import { colors, screenPadding } from '@/constants/tokens'
3 | import { getTopListDetail } from '@/helpers/userApi/getMusicSource'
4 | import { usePlaylists } from '@/store/library'
5 | import { defaultStyles } from '@/styles'
6 | import { Redirect, useLocalSearchParams } from 'expo-router'
7 | import React, { useEffect, useState } from 'react'
8 | import { ActivityIndicator, ScrollView, View } from 'react-native'
9 | import { Track } from 'react-native-track-player'
10 |
11 | const RadioListScreen = () => {
12 | const { name: playlistName } = useLocalSearchParams<{ name: string }>()
13 | const { playlists } = usePlaylists()
14 | const [topListDetail, setTopListDetail] = useState<{ musicList: Track[] } | null>(null)
15 | const [loading, setLoading] = useState(true)
16 |
17 | const playlist = playlists.find((playlist) => playlist.title === playlistName)
18 |
19 | useEffect(() => {
20 | const fetchTopListDetail = async () => {
21 | // console.log(playlistName)
22 | if (!playlist) {
23 | console.warn(`Playlist ${playlistName} was not found!`)
24 | setLoading(false)
25 | return
26 | }
27 |
28 | const detail = await getTopListDetail(playlist)
29 | setTopListDetail(detail)
30 | // console.log(JSON.stringify(detail));
31 | setLoading(false)
32 | }
33 | fetchTopListDetail()
34 | }, [])
35 |
36 | if (loading) {
37 | return (
38 |
46 |
47 |
48 | )
49 | }
50 |
51 | if (!playlist || !topListDetail) {
52 | return
53 | }
54 |
55 | return (
56 |
57 |
61 |
62 |
63 |
64 | )
65 | }
66 |
67 | export default RadioListScreen
68 |
--------------------------------------------------------------------------------
/src/store/mediaExtra.ts:
--------------------------------------------------------------------------------
1 |
2 | import safeParse from '@/utils/safeParse';
3 | import getOrCreateMMKV from '@/store/getOrCreateMMKV'
4 |
5 | // Internal Method
6 | const getPluginStore = (pluginName: string) => {
7 | return getOrCreateMMKV(`MediaExtra.${pluginName}`);
8 | };
9 |
10 | /** 获取meta信息 */
11 | const get = (mediaItem: ICommon.IMediaBase) => {
12 | if (mediaItem.platform && mediaItem.id) {
13 | const meta = getPluginStore(mediaItem.platform).getString(
14 | `${mediaItem.id}`,
15 | );
16 | if (!meta) {
17 | return null;
18 | }
19 |
20 | return safeParse(meta);
21 | }
22 |
23 | return null;
24 | };
25 |
26 | /** 设置meta信息 */
27 | const set = (mediaItem: ICommon.IMediaBase, meta: ICommon.IMediaMeta) => {
28 | if (mediaItem.platform && mediaItem.id) {
29 | const store = getPluginStore(mediaItem.platform);
30 | store.set(mediaItem.id, JSON.stringify(meta));
31 | return true;
32 | }
33 |
34 | return false;
35 | };
36 |
37 | /** 更新meta信息 */
38 | const update = (
39 | mediaItem: ICommon.IMediaBase,
40 | meta: Partial,
41 | ) => {
42 | if (mediaItem.platform && mediaItem.id) {
43 | const store = getPluginStore(mediaItem.platform);
44 | const originalMeta = get(mediaItem);
45 |
46 | store.set(
47 | `${mediaItem.id}`,
48 | JSON.stringify({
49 | ...(originalMeta || {}),
50 | ...meta,
51 | }),
52 | );
53 | return true;
54 | }
55 |
56 | return false;
57 | };
58 |
59 | /** 删除meta信息 */
60 | const remove = (mediaItem: ICommon.IMediaBase) => {
61 | if (mediaItem.platform && mediaItem.id) {
62 | const store = getPluginStore(mediaItem.platform);
63 | store.delete(`${mediaItem.id}`);
64 | return true;
65 | }
66 |
67 | return false;
68 | };
69 |
70 | const removeAll = (pluginName: string) => {
71 | const store = getPluginStore(pluginName);
72 | store.clearAll();
73 | };
74 |
75 | const MediaExtra = {
76 | get: get,
77 | set: set,
78 | update: update,
79 | remove: remove,
80 | removeAll: removeAll,
81 | };
82 |
83 | export default MediaExtra;
84 |
--------------------------------------------------------------------------------
/src/components/PlayerControls.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { FontAwesome6 } from '@expo/vector-icons'
3 | import { StyleSheet, TouchableOpacity, View, ViewStyle } from 'react-native'
4 | import TrackPlayer, { useIsPlaying } from 'react-native-track-player'
5 |
6 | import { useLibraryStore } from '@/store/library'
7 | import myTrackPlayer from '@/helpers/trackPlayerIndex'
8 |
9 | type PlayerControlsProps = {
10 | style?: ViewStyle
11 | }
12 |
13 | type PlayerButtonProps = {
14 | style?: ViewStyle
15 | iconSize?: number
16 | }
17 |
18 | export const PlayerControls = ({ style }: PlayerControlsProps) => {
19 | return (
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export const PlayPauseButton = ({ style, iconSize = 48 }: PlayerButtonProps) => {
33 | const { playing } = useIsPlaying()
34 |
35 | return (
36 |
37 |
41 |
42 |
43 |
44 | )
45 | }
46 |
47 |
48 |
49 | export const SkipToNextButton = ({ iconSize = 30 }: PlayerButtonProps) => {
50 | const { tracks, fetchTracks } = useLibraryStore((state) => ({
51 | tracks: state.tracks,
52 | fetchTracks: state.fetchTracks,
53 | }))
54 |
55 | return (
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | export const SkipToPreviousButton = ({ iconSize = 30 }: PlayerButtonProps) => {
63 |
64 |
65 | return (
66 |
67 |
68 |
69 | )
70 | }
71 |
72 | const styles = StyleSheet.create({
73 | container: {
74 | width: '100%',
75 | },
76 | row: {
77 | flexDirection: 'row',
78 | justifyContent: 'space-evenly',
79 | alignItems: 'center',
80 | },
81 | })
82 |
--------------------------------------------------------------------------------
/src/components/ShowPlayerListToggle.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react';
2 | import { Modal, View, Text, FlatList, TouchableOpacity, StyleSheet } from 'react-native';
3 | import { colors } from '@/constants/tokens';
4 | import { MaterialCommunityIcons } from '@expo/vector-icons';
5 | import { ComponentProps } from 'react';
6 | import { useLibraryStore } from '@/store/library'
7 | import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'
8 | import { router } from 'expo-router'
9 |
10 | type IconProps = Omit, 'name'>;
11 |
12 | export const ShowPlayerListToggle = ({ ...iconProps }: IconProps) => {
13 | const [modalVisible, setModalVisible] = useState(false);
14 | const nowPlaylist = useLibraryStore((state) => state.tracks); // 获取播放列表数据
15 |
16 | const showPlayList = () => {
17 |
18 | router.navigate('/(modals)/playList')
19 |
20 | };
21 |
22 | const hidePlayList = () => {
23 | setModalVisible(false);
24 | };
25 |
26 | return (
27 | <>
28 |
34 |
35 |
36 | >
37 | );
38 | };
39 |
40 | const styles = StyleSheet.create({
41 | modalOverlay: {
42 | flex: 1,
43 | backgroundColor: 'transparent',
44 | justifyContent: 'center',
45 | alignItems: 'center',
46 | borderRadius: 12,
47 | paddingVertical: 10,
48 | padding: 10,
49 | marginTop: 20,
50 | opacity: 0.75,
51 | },
52 | modalContent: {
53 | width: '90%',
54 |
55 | backgroundColor: 'white',
56 | borderRadius: 10,
57 | padding:50,
58 | alignItems: 'center',
59 | },
60 | modalTitle: {
61 | fontSize: 18,
62 | fontWeight: 'bold',
63 | marginBottom: 10,
64 | },
65 | listItem: {
66 | padding: 10,
67 | borderBottomWidth: 1,
68 | borderBottomColor: '#ccc',
69 | },
70 | trackTitle: {
71 | fontSize: 16,
72 | },
73 | trackArtist: {
74 | fontSize: 14,
75 | color: 'gray',
76 | },
77 | closeButton: {
78 | marginTop: 20,
79 | padding: 10,
80 | backgroundColor: colors.primary,
81 | borderRadius: 5,
82 | },
83 | closeButtonText: {
84 | color: 'black',
85 | fontWeight: 'bold',
86 | },
87 | });
88 |
--------------------------------------------------------------------------------
/src/utils/timingClose.ts:
--------------------------------------------------------------------------------
1 | import myTrackPlayer from '@/helpers/trackPlayerIndex'
2 |
3 | import StateMapper from '@/utils/stateMapper'
4 | import { useEffect, useRef, useState } from 'react'
5 | import BackgroundTimer from 'react-native-background-timer'
6 |
7 | import { logInfo } from '@/helpers/logger'
8 | import { NativeModule, NativeModules } from 'react-native'
9 |
10 | interface INativeUtils extends NativeModule {
11 | exitApp: () => void
12 | checkStoragePermission: () => Promise
13 | requestStoragePermission: () => void
14 | }
15 |
16 | const NativeUtils = NativeModules.NativeUtils
17 | let deadline: number | null = null
18 | const stateMapper = new StateMapper(() => deadline)
19 | // let closeAfterPlayEnd = false;
20 | // const closeAfterPlayEndStateMapper = new StateMapper(() => closeAfterPlayEnd);
21 | let timerId: any
22 |
23 | function setTimingClose(_deadline: number | null) {
24 | deadline = _deadline
25 | stateMapper.notify()
26 | timerId && BackgroundTimer.clearTimeout(timerId)
27 | if (_deadline) {
28 | logInfo('将在', (_deadline - Date.now()) / 1000 / 60, '分钟后暂停播放')
29 | timerId = BackgroundTimer.setTimeout(async () => {
30 | // todo: 播完整首歌再关闭
31 | await myTrackPlayer.pause()
32 | // NativeUtils.exitApp()
33 | // if(closeAfterPlayEnd) {
34 | // myTrackPlayer.addEventListener()
35 | // } else {
36 | // // 立即关闭
37 | // NativeUtils.exitApp();
38 | // }
39 | }, _deadline - Date.now())
40 | } else {
41 | timerId = null
42 | }
43 | }
44 |
45 | function useTimingClose() {
46 | const _deadline = stateMapper.useMappedState()
47 | const [countDown, setCountDown] = useState(deadline ? deadline - Date.now() : null)
48 | const intervalRef = useRef()
49 |
50 | useEffect(() => {
51 | // deadline改变时,更新定时器
52 | // 清除原有的定时器
53 | intervalRef.current && clearInterval(intervalRef.current)
54 | intervalRef.current = null
55 |
56 | // 清空定时
57 | if (!_deadline || _deadline <= Date.now()) {
58 | setCountDown(null)
59 | return
60 | } else {
61 | // 更新倒计时
62 | setCountDown(Math.max(_deadline - Date.now(), 0) / 1000)
63 | intervalRef.current = setInterval(() => {
64 | setCountDown(Math.max(_deadline - Date.now(), 0) / 1000)
65 | }, 1000)
66 | }
67 | }, [_deadline])
68 |
69 | return countDown
70 | }
71 |
72 | export { setTimingClose, useTimingClose }
73 |
--------------------------------------------------------------------------------
/src/constants/commonConst.ts:
--------------------------------------------------------------------------------
1 | import Animated, {Easing} from 'react-native-reanimated';
2 |
3 | export const internalSymbolKey = Symbol.for('$');
4 | // 加入播放列表/歌单的时间
5 | export const timeStampSymbol = Symbol.for('time-stamp');
6 | // 加入播放列表的辅助顺序
7 | export const sortIndexSymbol = Symbol.for('sort-index');
8 | export const internalSerializeKey = '$';
9 | export const localMusicSheetId = 'local-music-sheet';
10 | export const musicHistorySheetId = 'history-music-sheet';
11 |
12 | export const localPluginPlatform = '本地';
13 | export const localPluginHash = 'local-plugin-hash';
14 |
15 | export const internalFakeSoundKey = 'fake-key';
16 |
17 | const emptyFunction = () => {};
18 | Object.freeze(emptyFunction);
19 | export {emptyFunction};
20 |
21 | export enum RequestStateCode {
22 | /** 空闲 */
23 | IDLE = 0b00000000,
24 | PENDING_FIRST_PAGE = 0b00000010,
25 | LOADING = 0b00000010,
26 | /** 检索中 */
27 | PENDING_REST_PAGE = 0b00000011,
28 | /** 部分结束 */
29 | PARTLY_DONE = 0b00000100,
30 | /** 全部结束 */
31 | FINISHED = 0b0001000,
32 | /** 出错了 */
33 | ERROR = 0b10000000,
34 | }
35 |
36 | export const StorageKeys = {
37 | /** @deprecated */
38 | MediaMetaKeys: 'media-meta-keys',
39 | PluginMetaKey: 'plugin-meta',
40 | MediaCache: 'media-cache',
41 | LocalMusicSheet: 'local-music-sheet',
42 | };
43 |
44 | export const CacheControl = {
45 | Cache: 'cache',
46 | NoCache: 'no-cache',
47 | NoStore: 'no-store',
48 | };
49 |
50 | export const supportLocalMediaType = [
51 | '.mp3',
52 | '.flac',
53 | '.wma',
54 | '.wav',
55 | '.m4a',
56 | '.ogg',
57 | '.acc',
58 | '.aac',
59 | '.ape',
60 | '.opus',
61 | ];
62 |
63 | /** 全局事件 */
64 | export enum EDeviceEvents {
65 | /** 刷新歌词 */
66 | REFRESH_LYRIC = 'refresh-lyric',
67 | }
68 |
69 | const ANIMATION_EASING: Animated.EasingFunction = Easing.out(Easing.exp);
70 | const ANIMATION_DURATION = 150;
71 |
72 | const animationFast = {
73 | duration: ANIMATION_DURATION,
74 | easing: ANIMATION_EASING,
75 | };
76 |
77 | const animationNormal = {
78 | duration: 250,
79 | easing: ANIMATION_EASING,
80 | };
81 |
82 | const animationSlow = {
83 | duration: 500,
84 | easing: ANIMATION_EASING,
85 | };
86 |
87 | export const timingConfig = {
88 | animationFast,
89 | animationNormal,
90 | animationSlow,
91 | };
92 |
--------------------------------------------------------------------------------
/src/types/sync_common.d.ts:
--------------------------------------------------------------------------------
1 | type WarpSyncHandlerActions = {
2 | [K in keyof Actions]: (...args: [Socket, ...Parameters]) => ReturnType
3 | }
4 |
5 | declare namespace LX {
6 | namespace Sync {
7 | type ServerSyncActions = WarpPromiseRecord<{
8 | onFeatureChanged: (feature: EnabledFeatures) => void
9 | }>
10 | type ServerSyncHandlerActions = WarpSyncHandlerActions
11 |
12 | type ServerSyncListActions = WarpPromiseRecord<{
13 | onListSyncAction: (action: LX.Sync.List.ActionList) => void
14 | }>
15 | type ServerSyncHandlerListActions = WarpSyncHandlerActions
16 |
17 | type ServerSyncDislikeActions = WarpPromiseRecord<{
18 | onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void
19 | }>
20 | type ServerSyncHandlerDislikeActions = WarpSyncHandlerActions
21 |
22 | type ClientSyncActions = WarpPromiseRecord<{
23 | getEnabledFeatures: (serverType: ServerType, supportedFeatures: SupportedFeatures) => EnabledFeatures
24 | finished: () => void
25 | }>
26 | type ClientSyncHandlerActions = WarpSyncHandlerActions
27 |
28 | type ClientSyncListActions = WarpPromiseRecord<{
29 | onListSyncAction: (action: LX.Sync.List.ActionList) => void
30 | list_sync_get_md5: () => string
31 | list_sync_get_sync_mode: () => LX.Sync.List.SyncMode
32 | list_sync_get_list_data: () => LX.Sync.List.ListData
33 | list_sync_set_list_data: (data: LX.Sync.List.ListData) => void
34 | list_sync_finished: () => void
35 | }>
36 | type ClientSyncHandlerListActions = WarpSyncHandlerActions
37 |
38 | type ClientSyncDislikeActions = WarpPromiseRecord<{
39 | onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void
40 | dislike_sync_get_md5: () => string
41 | dislike_sync_get_sync_mode: () => LX.Sync.Dislike.SyncMode
42 | dislike_sync_get_list_data: () => LX.Dislike.DislikeRules
43 | dislike_sync_set_list_data: (data: LX.Dislike.DislikeRules) => void
44 | dislike_sync_finished: () => void
45 | }>
46 | type ClientSyncHandlerDislikeActions = WarpSyncHandlerActions
47 | }
48 | }
49 |
50 |
51 |
--------------------------------------------------------------------------------
/src/store/PersistStatus.ts:
--------------------------------------------------------------------------------
1 | import getOrCreateMMKV from '@/store/getOrCreateMMKV'
2 | import safeParse from '@/utils/safeParse'
3 | import { useEffect, useState } from 'react'
4 |
5 | const PersistConfig = {
6 | PersistStatus: 'appPersistStatus',
7 | }
8 |
9 | interface IPersistConfig {
10 | 'music.musicItem': IMusic.IMusicItem //当前播放
11 | 'music.progress': number
12 | 'music.repeatMode': string
13 | //播放列表
14 | 'music.play-list': IMusic.IMusicItem[]
15 | 'music.favorites': IMusic.IMusicItem[]
16 | 'music.rate': number
17 | 'music.quality': IMusic.IQualityKey
18 | 'app.skipVersion': string
19 | 'app.pluginUpdateTime': number
20 | 'lyric.showTranslation': boolean
21 | 'lyric.detailFontSize': number
22 | 'app.logo': 'Default' | 'Logo1'
23 | //歌单
24 | 'music.playLists': IMusic.PlayList[]
25 | //音源
26 | 'music.musicApi': IMusic.MusicApi[]
27 | //当前选择的音源
28 | 'music.selectedMusicApi': IMusic.MusicApi
29 | //已导入的本地音乐
30 | 'music.importedLocalMusic': IMusic.IMusicItem[]
31 | 'music.autoCacheLocal': boolean
32 | 'app.language': string
33 | 'music.isCachedIconVisible': boolean
34 | 'music.songsNumsToLoad': number
35 | }
36 |
37 | function set(key: K, value: IPersistConfig[K] | undefined) {
38 | const store = getOrCreateMMKV(PersistConfig.PersistStatus)
39 | if (value === undefined) {
40 | store.delete(key)
41 | } else {
42 | store.set(key, JSON.stringify(value))
43 | }
44 | }
45 |
46 | function get(key: K): IPersistConfig[K] | null {
47 | const store = getOrCreateMMKV(PersistConfig.PersistStatus)
48 | const raw = store.getString(key)
49 | if (raw) {
50 | return safeParse(raw) as IPersistConfig[K]
51 | }
52 | return null
53 | }
54 |
55 | function useValue(
56 | key: K,
57 | defaultValue?: IPersistConfig[K],
58 | ): IPersistConfig[K] | null {
59 | const [state, setState] = useState(get(key) ?? defaultValue ?? null)
60 |
61 | useEffect(() => {
62 | const store = getOrCreateMMKV(PersistConfig.PersistStatus)
63 | const sub = store.addOnValueChangedListener((changedKey) => {
64 | if (key === changedKey) {
65 | setState(get(key))
66 | }
67 | })
68 |
69 | return () => {
70 | sub.remove()
71 | }
72 | }, [key])
73 |
74 | return state
75 | }
76 |
77 | const PersistStatus = {
78 | get,
79 | set,
80 | useValue,
81 | }
82 |
83 | export default PersistStatus
84 |
--------------------------------------------------------------------------------
/src/store/playList.ts:
--------------------------------------------------------------------------------
1 |
2 | import {GlobalState} from '@/utils/stateMapper';
3 | import PersistStatus from '@/store/PersistStatus'
4 |
5 | /** 音乐队列 */
6 | const playListStore = new GlobalState([]);
7 |
8 | /** 下标映射 */
9 | let playListIndexMap: Record> = {};
10 |
11 | /**
12 | * 设置播放队列
13 | * @param newPlayList 新的播放队列
14 | */
15 | export function setPlayList(
16 | newPlayList: IMusic.IMusicItem[],
17 | shouldSave = true,
18 | ) {
19 | playListStore.setValue(newPlayList);
20 | const newIndexMap: Record> = {};
21 | newPlayList.forEach((item, index) => {
22 | // 映射中不存在
23 | if (!newIndexMap[item.platform]) {
24 | newIndexMap[item.platform] = {
25 | [item.id]: index,
26 | };
27 | } else {
28 | // 修改映射
29 | newIndexMap[item.platform][item.id] = index;
30 | }
31 | });
32 | playListIndexMap = newIndexMap;
33 | if (shouldSave) {
34 | PersistStatus.set('music.playList', newPlayList);
35 | }
36 | }
37 |
38 | /**
39 | * 获取当前的播放队列
40 | */
41 | export const getPlayList = playListStore.getValue;
42 |
43 | /**
44 | * hook
45 | */
46 | export const usePlayList = playListStore.useValue;
47 |
48 | /**
49 | * 寻找歌曲在播放列表中的下标
50 | * @param musicItem 音乐
51 | * @returns 下标
52 | */
53 | export function getMusicIndex(musicItem?: IMusic.IMusicItem | null) {
54 | if (!musicItem) {
55 | return -1;
56 | }
57 | return playListIndexMap[musicItem.platform]?.[musicItem.id] ?? -1;
58 | }
59 |
60 | /**
61 | * 歌曲是否在播放队列中
62 | * @param musicItem 音乐
63 | * @returns 是否在播放队列中
64 | */
65 | export function isInPlayList(musicItem?: IMusic.IMusicItem | null) {
66 | if (!musicItem) {
67 | return false;
68 | }
69 |
70 | return playListIndexMap[musicItem.platform]?.[musicItem.id] > -1;
71 | }
72 |
73 | /**
74 | * 获取第i个位置的歌曲
75 | * @param index 下标
76 | */
77 | export function getPlayListMusicAt(index: number): IMusic.IMusicItem | null {
78 | const playList = playListStore.getValue();
79 |
80 | const len = playList.length;
81 | if (len === 0) {
82 | return null;
83 | }
84 |
85 | return playList[(index + len) % len];
86 | }
87 |
88 | /**
89 | * 播放队列是否为空
90 | * @returns
91 | */
92 | export function isPlayListEmpty() {
93 | return playListStore.getValue().length === 0;
94 | }
95 |
--------------------------------------------------------------------------------
/src/components/FloatingPlayer.tsx:
--------------------------------------------------------------------------------
1 | import { PlayPauseButton, SkipToNextButton } from '@/components/PlayerControls'
2 | import { unknownTrackImageUri } from '@/constants/images'
3 | import { useLastActiveTrack } from '@/hooks/useLastActiveTrack'
4 | import { defaultStyles } from '@/styles'
5 | import { useRouter } from 'expo-router'
6 | import { StyleSheet, TouchableOpacity, View, ViewProps } from 'react-native'
7 | import FastImage from 'react-native-fast-image'
8 | import { useActiveTrack } from 'react-native-track-player'
9 | import { MovingText } from './MovingText'
10 |
11 | export const FloatingPlayer = ({ style }: ViewProps) => {
12 | const router = useRouter()
13 |
14 | const activeTrack = useActiveTrack()
15 | const lastActiveTrack = useLastActiveTrack()
16 |
17 | const displayedTrack = activeTrack ?? lastActiveTrack
18 |
19 | const handlePress = () => {
20 | router.navigate('/player')
21 | }
22 |
23 | if (!displayedTrack) return null
24 |
25 | return (
26 |
27 | <>
28 |
34 |
35 |
36 |
41 |
42 |
43 |
44 | {/**/}
45 |
46 |
47 |
48 | >
49 |
50 | )
51 | }
52 |
53 | const styles = StyleSheet.create({
54 | container: {
55 | flexDirection: 'row',
56 | alignItems: 'center',
57 | backgroundColor: '#252525',
58 | padding: 8,
59 | borderRadius: 12,
60 | paddingVertical: 10,
61 | },
62 | trackArtworkImage: {
63 | width: 40,
64 | height: 40,
65 | borderRadius: 8,
66 | },
67 | trackTitleContainer: {
68 | flex: 1,
69 | overflow: 'hidden',
70 | marginLeft: 10,
71 | },
72 | trackTitle: {
73 | ...defaultStyles.text,
74 | fontSize: 18,
75 | fontWeight: '600',
76 | paddingLeft: 10,
77 | },
78 | trackControlsContainer: {
79 | flexDirection: 'row',
80 | alignItems: 'center',
81 | columnGap: 20,
82 | marginRight: 16,
83 | paddingLeft: 16,
84 | },
85 | })
86 |
--------------------------------------------------------------------------------
/src/store/trackViewList.ts:
--------------------------------------------------------------------------------
1 |
2 | import {GlobalState} from '@/utils/stateMapper';
3 | import PersistStatus from '@/store/PersistStatus'
4 |
5 | /** 音乐队列 */
6 | const trackViewList = new GlobalState([]);
7 |
8 | /** 下标映射 */
9 | let playListIndexMap: Record> = {};
10 |
11 | /**
12 | * 设置播放队列
13 | * @param newPlayList 新的播放队列
14 | */
15 | export function setTrackViewList(
16 | newPlayList: IMusic.IMusicItem[],
17 | shouldSave = true,
18 | ) {
19 | trackViewList.setValue(newPlayList);
20 | const newIndexMap: Record> = {};
21 | newPlayList.forEach((item, index) => {
22 | // 映射中不存在
23 | if (!newIndexMap[item.platform]) {
24 | newIndexMap[item.platform] = {
25 | [item.id]: index,
26 | };
27 | } else {
28 | // 修改映射
29 | newIndexMap[item.platform][item.id] = index;
30 | }
31 | });
32 | playListIndexMap = newIndexMap;
33 | if (shouldSave) {
34 | PersistStatus.set('music.playList', newPlayList);
35 | }
36 | }
37 |
38 | /**
39 | * 获取当前的播放队列
40 | */
41 | export const getTrackViewList = trackViewList.getValue;
42 |
43 | /**
44 | * hook
45 | */
46 | export const useTrackViewList = trackViewList.useValue;
47 |
48 | /**
49 | * 寻找歌曲在播放列表中的下标
50 | * @param musicItem 音乐
51 | * @returns 下标
52 | */
53 | export function getTrackViewListIndex(musicItem?: IMusic.IMusicItem | null) {
54 | if (!musicItem) {
55 | return -1;
56 | }
57 | return playListIndexMap[musicItem.platform]?.[musicItem.id] ?? -1;
58 | }
59 |
60 | /**
61 | * 歌曲是否在播放队列中
62 | * @param musicItem 音乐
63 | * @returns 是否在播放队列中
64 | */
65 | export function isInTrackViewList(musicItem?: IMusic.IMusicItem | null) {
66 | if (!musicItem) {
67 | return false;
68 | }
69 |
70 | return playListIndexMap[musicItem.platform]?.[musicItem.id] > -1;
71 | }
72 |
73 | /**
74 | * 获取第i个位置的歌曲
75 | * @param index 下标
76 | */
77 | export function getTrackViewListMusicAt(index: number): IMusic.IMusicItem | null {
78 | const playList = trackViewList.getValue();
79 | const len = playList.length;
80 | if (len === 0) {
81 | return null;
82 | }
83 |
84 | return playList[(index + len) % len];
85 | }
86 |
87 | /**
88 | * 播放队列是否为空
89 | * @returns
90 | */
91 | export function isTrackViewListEmpty() {
92 | return trackViewList.getValue().length === 0;
93 | }
94 |
--------------------------------------------------------------------------------
/src/app/(modals)/addToPlaylist.tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistsListModal } from '@/components/PlaylistsListModal'
2 | import { screenPadding } from '@/constants/tokens'
3 | import myTrackPlayer from '@/helpers/trackPlayerIndex'
4 | import { useFavorites } from '@/store/library'
5 | import { defaultStyles } from '@/styles'
6 | import { useHeaderHeight } from '@react-navigation/elements'
7 | import { useLocalSearchParams, useRouter } from 'expo-router'
8 | import { Alert, StyleSheet } from 'react-native'
9 | import { SafeAreaView } from 'react-native-safe-area-context'
10 | import { Track } from 'react-native-track-player'
11 |
12 | const AddToPlaylistModal = () => {
13 | const router = useRouter()
14 | const params = useLocalSearchParams()
15 |
16 | const track: IMusic.IMusicItem = {
17 | title: params.title as string,
18 | album: params.album as string,
19 | artwork: params.artwork as string,
20 | artist: params.artist as string,
21 | id: params.id as string,
22 | url: (params.url as string) || 'Unknown',
23 | platform: (params.platform as string) || 'tx',
24 | duration: typeof params.duration === 'string' ? parseInt(params.duration, 10) : 0,
25 | }
26 | const headerHeight = useHeaderHeight()
27 |
28 | const { favorites, toggleTrackFavorite } = useFavorites()
29 | // track was not found
30 | if (!track) {
31 | return null
32 | }
33 |
34 | const handlePlaylistPress = async (playlist: IMusic.PlayList) => {
35 | // console.log('playlist', playlist)
36 | if (playlist.id === 'favorites') {
37 | if (favorites.find((item) => item.id === track.id)) {
38 | console.log('已收藏')
39 | } else {
40 | toggleTrackFavorite(track as Track)
41 | }
42 | } else {
43 | myTrackPlayer.addSongToStoredPlayList(playlist, track)
44 | }
45 | // should close the modal
46 | router.dismiss()
47 | Alert.alert('成功', '添加成功')
48 |
49 | // if the current queue is the playlist we're adding to, add the track at the end of the queue
50 | // if (activeQueueId?.startsWith(playlist.name)) {
51 | // await TrackPlayer.add(track)
52 | // }
53 | }
54 |
55 | return (
56 |
57 |
58 |
59 | )
60 | }
61 |
62 | const styles = StyleSheet.create({
63 | modalContainer: {
64 | ...defaultStyles.container,
65 | paddingHorizontal: screenPadding.horizontal,
66 | },
67 | })
68 |
69 | export default AddToPlaylistModal
70 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/music/player/gyc/MainActivity.kt:
--------------------------------------------------------------------------------
1 | package com.music.player.gyc
2 |
3 | import android.os.Build
4 | import android.os.Bundle
5 |
6 | import com.facebook.react.ReactActivity
7 | import com.facebook.react.ReactActivityDelegate
8 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.fabricEnabled
9 | import com.facebook.react.defaults.DefaultReactActivityDelegate
10 |
11 | import expo.modules.ReactActivityDelegateWrapper
12 |
13 | class MainActivity : ReactActivity() {
14 | override fun onCreate(savedInstanceState: Bundle?) {
15 | // Set the theme to AppTheme BEFORE onCreate to support
16 | // coloring the background, status bar, and navigation bar.
17 | // This is required for expo-splash-screen.
18 | setTheme(R.style.AppTheme);
19 | super.onCreate(null)
20 | }
21 |
22 | /**
23 | * Returns the name of the main component registered from JavaScript. This is used to schedule
24 | * rendering of the component.
25 | */
26 | override fun getMainComponentName(): String = "main"
27 |
28 | /**
29 | * Returns the instance of the [ReactActivityDelegate]. We use [DefaultReactActivityDelegate]
30 | * which allows you to enable New Architecture with a single boolean flags [fabricEnabled]
31 | */
32 | override fun createReactActivityDelegate(): ReactActivityDelegate {
33 | return ReactActivityDelegateWrapper(
34 | this,
35 | BuildConfig.IS_NEW_ARCHITECTURE_ENABLED,
36 | object : DefaultReactActivityDelegate(
37 | this,
38 | mainComponentName,
39 | fabricEnabled
40 | ){})
41 | }
42 |
43 | /**
44 | * Align the back button behavior with Android S
45 | * where moving root activities to background instead of finishing activities.
46 | * @see onBackPressed
47 | */
48 | override fun invokeDefaultOnBackPressed() {
49 | if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.R) {
50 | if (!moveTaskToBack(false)) {
51 | // For non-root activities, use the default implementation to finish them.
52 | super.invokeDefaultOnBackPressed()
53 | }
54 | return
55 | }
56 |
57 | // Use the default back button implementation on Android S
58 | // because it's doing more than [Activity.moveTaskToBack] in fact.
59 | super.invokeDefaultOnBackPressed()
60 | }
61 | }
62 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "music-player",
3 | "version": "1.0.3",
4 | "main": "expo-router/entry",
5 | "scripts": {
6 | "android": "expo run:android",
7 | "ios": "expo run:ios",
8 | "lint": "eslint .",
9 | "postinstall": "patch-package"
10 | },
11 | "dependencies": {
12 | "@react-native-async-storage/async-storage": "^1.23.1",
13 | "@react-native-menu/menu": "^0.9.1",
14 | "axios": "^1.7.2",
15 | "crypto-js": "^4.2.0",
16 | "expo": "~50.0.14",
17 | "expo-blur": "~12.9.2",
18 | "expo-constants": "~15.4.5",
19 | "expo-dev-client": "~3.3.11",
20 | "expo-document-picker": "^12.0.2",
21 | "expo-haptics": "~12.8.1",
22 | "expo-image-picker": "~14.7.1",
23 | "expo-keep-awake": "~12.8.2",
24 | "expo-linear-gradient": "~12.7.2",
25 | "expo-linking": "~6.2.2",
26 | "expo-localization": "~14.8.4",
27 | "expo-music-info-2": "^2.0.0",
28 | "expo-router": "~3.4.8",
29 | "expo-share-intent": "^1.9.0",
30 | "expo-status-bar": "~1.11.1",
31 | "i18n-js": "^4.4.3",
32 | "iconv-lite": "^0.6.3",
33 | "immer": "^10.1.1",
34 | "lodash.shuffle": "^4.2.0",
35 | "patch-package": "^8.0.0",
36 | "react": "18.2.0",
37 | "react-native": "0.73.6",
38 | "react-native-awesome-slider": "^2.5.1",
39 | "react-native-background-timer": "^2.4.1",
40 | "react-native-fast-image": "^8.6.3",
41 | "react-native-fs": "^2.20.0",
42 | "react-native-gesture-handler": "~2.18.0",
43 | "react-native-image-colors": "^2.4.0",
44 | "react-native-loader-kit": "^2.0.8",
45 | "react-native-lyric": "^1.0.2",
46 | "react-native-mmkv": "^2.12.2",
47 | "react-native-quick-base64": "^2.1.2",
48 | "react-native-quick-crypto": "^0.7.0",
49 | "react-native-quick-md5": "^3.0.6",
50 | "react-native-reanimated": "~3.6.2",
51 | "react-native-safe-area-context": "4.8.2",
52 | "react-native-screens": "~3.29.0",
53 | "react-native-simple-crypto": "^0.2.15",
54 | "react-native-toast-message": "^2.2.1",
55 | "react-native-track-player": "^4.1.1",
56 | "react-native-volume-manager": "^1.10.0",
57 | "ts-pattern": "^5.0.8",
58 | "zustand": "^4.5.2"
59 | },
60 | "devDependencies": {
61 | "@babel/core": "^7.20.0",
62 | "@types/lodash.shuffle": "^4.2.9",
63 | "@types/react": "~18.2.0",
64 | "@typescript-eslint/eslint-plugin": "^7.4.0",
65 | "@typescript-eslint/parser": "^7.4.0",
66 | "eslint": "^8.57.0",
67 | "eslint-plugin-react": "^7.34.1",
68 | "eslint-plugin-react-hooks": "^4.6.0",
69 | "prettier": "^3.2.5",
70 | "typescript": "^5.1.3"
71 | },
72 | "private": true
73 | }
74 |
--------------------------------------------------------------------------------
/src/components/ArtistTracksList.tsx:
--------------------------------------------------------------------------------
1 | import { unknownArtistImageUri } from '@/constants/images'
2 | import { fontSize } from '@/constants/tokens'
3 | import { trackTitleFilter } from '@/helpers/filter'
4 | import { generateTracksListId } from '@/helpers/miscellaneous'
5 | import { Artist } from '@/helpers/types'
6 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
7 | import { defaultStyles } from '@/styles'
8 | import i18n from '@/utils/i18n'
9 | import { useMemo } from 'react'
10 | import { StyleSheet, Text, View } from 'react-native'
11 | import FastImage from 'react-native-fast-image'
12 | import { QueueControls } from './QueueControls'
13 | import { TracksList } from './TracksList'
14 | export const ArtistTracksList = ({ artist }: { artist: Artist }) => {
15 | const search = useNavigationSearch({
16 | searchBarOptions: {
17 | hideWhenScrolling: true,
18 | placeholder: i18n.t('find.inSongs'),
19 | cancelButtonText: i18n.t('find.cancel'),
20 | },
21 | })
22 |
23 | const filteredArtistTracks = useMemo(() => {
24 | return artist.tracks.filter(trackTitleFilter(search))
25 | }, [artist.tracks, search])
26 |
27 | return (
28 |
35 |
36 |
43 |
44 |
45 |
46 | {artist.name}
47 |
48 |
49 | {search.length === 0 && (
50 |
51 | )}
52 |
53 | }
54 | tracks={filteredArtistTracks}
55 | />
56 | )
57 | }
58 |
59 | const styles = StyleSheet.create({
60 | artistHeaderContainer: {
61 | flex: 1,
62 | marginBottom: 32,
63 | },
64 | artworkImageContainer: {
65 | flexDirection: 'row',
66 | justifyContent: 'center',
67 | height: 200,
68 | },
69 | artistImage: {
70 | width: '60%',
71 | height: '100%',
72 | resizeMode: 'cover',
73 | borderRadius: 128,
74 | },
75 | artistNameText: {
76 | ...defaultStyles.text,
77 | marginTop: 22,
78 | textAlign: 'center',
79 | fontSize: fontSize.lg,
80 | fontWeight: '800',
81 | },
82 | })
83 |
--------------------------------------------------------------------------------
/android/gradle.properties:
--------------------------------------------------------------------------------
1 | # Project-wide Gradle settings.
2 |
3 | # IDE (e.g. Android Studio) users:
4 | # Gradle settings configured through the IDE *will override*
5 | # any settings specified in this file.
6 |
7 | # For more details on how to configure your build environment visit
8 | # http://www.gradle.org/docs/current/userguide/build_environment.html
9 |
10 | # Specifies the JVM arguments used for the daemon process.
11 | # The setting is particularly useful for tweaking memory settings.
12 | # Default value: -Xmx512m -XX:MaxMetaspaceSize=256m
13 | org.gradle.jvmargs=-Xmx2048m -XX:MaxMetaspaceSize=512m
14 |
15 | # When configured, Gradle will run in incubating parallel mode.
16 | # This option should only be used with decoupled projects. More details, visit
17 | # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
18 | # org.gradle.parallel=true
19 |
20 | # AndroidX package structure to make it clearer which packages are bundled with the
21 | # Android operating system, and which are packaged with your app's APK
22 | # https://developer.android.com/topic/libraries/support-library/androidx-rn
23 | android.useAndroidX=true
24 |
25 | # Automatically convert third-party libraries to use AndroidX
26 | android.enableJetifier=true
27 |
28 | # Use this property to specify which architecture you want to build.
29 | # You can also override it from the CLI using
30 | # ./gradlew -PreactNativeArchitectures=x86_64
31 | reactNativeArchitectures=armeabi-v7a,arm64-v8a,x86,x86_64
32 |
33 | # Use this property to enable support to the new architecture.
34 | # This will allow you to use TurboModules and the Fabric render in
35 | # your application. You should enable this flag either if you want
36 | # to write custom TurboModules/Fabric components OR use libraries that
37 | # are providing them.
38 | newArchEnabled=false
39 |
40 | # Use this property to enable or disable the Hermes JS engine.
41 | # If set to false, you will be using JSC instead.
42 | hermesEnabled=true
43 |
44 | # Enable GIF support in React Native images (~200 B increase)
45 | expo.gif.enabled=true
46 | # Enable webp support in React Native images (~85 KB increase)
47 | expo.webp.enabled=true
48 | # Enable animated webp support (~3.4 MB increase)
49 | # Disabled by default because iOS doesn't support animated webp
50 | expo.webp.animated=false
51 |
52 | # Enable network inspector
53 | EX_DEV_CLIENT_NETWORK_INSPECTOR=true
54 |
55 | # Use legacy packaging to compress native libraries in the resulting APK.
56 | expo.useLegacyPackaging=false
57 |
--------------------------------------------------------------------------------
/src/types/common.d.ts:
--------------------------------------------------------------------------------
1 | // import './app_setting'
2 |
3 | declare namespace LX {
4 | type OnlineSource = 'kw' | 'kg' | 'tx' | 'wy' | 'mg'
5 | type Source = OnlineSource | 'local'
6 | type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | '192k' | 'ape' | 'wav'
7 | type QualityList = Partial>
8 |
9 | type ShareType = 'system' | 'clipboard'
10 |
11 | type UpdateStatus = 'downloaded' | 'downloading' | 'error' | 'checking' | 'idle'
12 | interface VersionInfo {
13 | version: string
14 | desc: string
15 | }
16 | }
17 | declare namespace ICommon {
18 | /** 支持搜索的媒体类型 */
19 | export type SupportMediaType =
20 | | 'music'
21 | | 'album'
22 | | 'artist'
23 | | 'sheet'
24 | | 'lyric';
25 |
26 | /** 媒体定义 */
27 | export type SupportMediaItemBase = {
28 | music: IMusic.IMusicItemBase;
29 | album: IAlbum.IAlbumItemBase;
30 | artist: IArtist.IArtistItemBase;
31 | sheet: IMusic.IMusicSheetItemBase;
32 | lyric: ILyric.ILyricItem;
33 | };
34 |
35 | export type IUnique = {
36 | id: string;
37 | [k: string | symbol]: any;
38 | };
39 |
40 | export type IMediaBase = {
41 | id: string;
42 | platform: string;
43 | $?: any;
44 | [k: symbol]: any;
45 | [k: string]: any;
46 | };
47 |
48 | /** 一些额外信息 */
49 | export type IMediaMeta = {
50 | /** 关联歌词信息 */
51 | associatedLrc?: IMediaBase;
52 | /** 是否下载过 TODO: 删去 */
53 | downloaded?: boolean;
54 | /** 本地下载路径 */
55 | localPath?: string;
56 | /** 补充的音乐信息 */
57 | mediaItem?: Partial;
58 | /** 歌词偏移 */
59 | lyricOffset?: number;
60 |
61 | lrc?: string;
62 | associatedLrc?: IMediaBase;
63 | headers?: Record;
64 | url?: string;
65 | id?: string;
66 | platform?: string;
67 | qualities?: IMusic.IQuality;
68 | $?: {
69 | local?: {
70 | localLrc?: string;
71 | [k: string]: any;
72 | };
73 | [k: string]: any;
74 | };
75 | [k: string]: any;
76 | [k: symbol]: any;
77 | };
78 |
79 | export type WithMusicList = T & {
80 | musicList?: IMusic.IMusicItem[];
81 | };
82 |
83 | export type PaginationResponse = {
84 | isEnd?: boolean;
85 | data?: T[];
86 | };
87 |
88 | export interface IPoint {
89 | x: number;
90 | y: number;
91 | }
92 | }
93 |
94 |
--------------------------------------------------------------------------------
/src/app/(tabs)/favorites/index.tsx:
--------------------------------------------------------------------------------
1 | import localImage from '@/assets/local.png'
2 | import { PlaylistsList } from '@/components/PlaylistsList'
3 | import { screenPadding } from '@/constants/tokens'
4 | import { playListsStore } from '@/helpers/trackPlayerIndex'
5 | import { Playlist } from '@/helpers/types'
6 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
7 | import { defaultStyles } from '@/styles'
8 | import i18n from '@/utils/i18n'
9 | import { router } from 'expo-router'
10 | import { useMemo } from 'react'
11 | import { Image, ScrollView, View } from 'react-native'
12 |
13 | const FavoritesScreen = () => {
14 | const search = useNavigationSearch({
15 | searchBarOptions: {
16 | placeholder: i18n.t('find.inFavorites'),
17 | cancelButtonText: i18n.t('find.cancel'),
18 | },
19 | })
20 |
21 | const favoritePlayListItem = {
22 | name: 'Favorites',
23 | id: 'favorites',
24 | tracks: [],
25 | title: i18n.t('appTab.favoritesSongs'),
26 | coverImg: 'https://y.qq.com/mediastyle/global/img/cover_like.png?max_age=2592000',
27 | description: i18n.t('appTab.favoritesSongs'),
28 | }
29 |
30 | const localPlayListItem = {
31 | name: 'Local',
32 | id: 'local',
33 | tracks: [],
34 | title: i18n.t('appTab.localOrCachedSongs'),
35 | coverImg: Image.resolveAssetSource(localImage).uri,
36 | description: i18n.t('appTab.localOrCachedSongs'),
37 | }
38 | const storedPlayLists = playListsStore.useValue() || []
39 | const playLists = [favoritePlayListItem, localPlayListItem, ...storedPlayLists]
40 |
41 | const filteredPlayLists = useMemo(() => {
42 | if (!search) return playLists as Playlist[]
43 |
44 | return playLists.filter((playlist: Playlist) =>
45 | playlist.name.toLowerCase().includes(search.toLowerCase()),
46 | ) as Playlist[]
47 | }, [search, playLists, storedPlayLists])
48 | const handlePlaylistPress = (playlist: Playlist) => {
49 | if (playlist.name == 'Favorites') {
50 | router.push(`/(tabs)/favorites/favoriteMusic`)
51 | } else if (playlist.name == 'Local') {
52 | router.push(`/(tabs)/favorites/localMusic`)
53 | } else {
54 | router.push(`/(tabs)/favorites/${playlist.id}`)
55 | }
56 | }
57 | return (
58 |
59 |
65 |
70 |
71 |
72 | )
73 | }
74 |
75 | export default FavoritesScreen
76 |
--------------------------------------------------------------------------------
/src/app/(tabs)/_layout.tsx:
--------------------------------------------------------------------------------
1 | import { FloatingPlayer } from '@/components/FloatingPlayer'
2 | import { colors, fontSize } from '@/constants/tokens'
3 | import i18n, { nowLanguage } from '@/utils/i18n'
4 | import { FontAwesome, Ionicons, MaterialCommunityIcons } from '@expo/vector-icons'
5 | import { BlurView } from 'expo-blur'
6 | import { Tabs } from 'expo-router'
7 | import React from 'react'
8 | import { StyleSheet } from 'react-native'
9 | const TabsNavigation = () => {
10 | const language = nowLanguage.useValue()
11 | return (
12 | <>
13 | (
29 |
38 | ),
39 | }}
40 | >
41 | (
46 |
47 | ),
48 | }}
49 | />
50 | ,
55 | }}
56 | />
57 | , //当你定义 tabBarIcon 时,React Navigation 会自动传递一些参数给你,其中包括 color、focused 和 size。这些参数会根据当前 Tab 的选中状态和主题来动态变化。
62 | }}
63 | />
64 | (
69 |
70 | ),
71 | }}
72 | />
73 |
74 |
75 |
83 | >
84 | )
85 | }
86 |
87 | export default TabsNavigation
88 |
--------------------------------------------------------------------------------
/src/components/PlaylistsListModal.tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistListItem } from '@/components/PlaylistListItem'
2 | import { unknownTrackImageUri } from '@/constants/images'
3 | import { playListsStore } from '@/helpers/trackPlayerIndex'
4 | import { Playlist } from '@/helpers/types'
5 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
6 | import { utilsStyles } from '@/styles'
7 | import i18n from '@/utils/i18n'
8 | import { useMemo } from 'react'
9 | import { FlatList, FlatListProps, Text, View } from 'react-native'
10 | import FastImage from 'react-native-fast-image'
11 | type PlaylistsListProps = {
12 | onPlaylistPress: (playlist: IMusic.PlayList) => void
13 | } & Partial>
14 |
15 | const ItemDivider = () => (
16 |
17 | )
18 |
19 | export const PlaylistsListModal = ({
20 | onPlaylistPress: handlePlaylistPress,
21 | ...flatListProps
22 | }: PlaylistsListProps) => {
23 | const search = useNavigationSearch({
24 | searchBarOptions: {
25 | placeholder: i18n.t('find.inPlaylist'),
26 | cancelButtonText: i18n.t('find.cancel'),
27 | },
28 | })
29 | const favoritePlayListItem = useMemo(
30 | () => ({
31 | name: 'Favorites',
32 | id: 'favorites',
33 | tracks: [],
34 | title: '喜欢的歌曲',
35 | coverImg: 'https://y.qq.com/mediastyle/global/img/cover_like.png?max_age=2592000',
36 | description: '喜欢的歌曲',
37 | }),
38 | [],
39 | )
40 | const storedPlayLists = playListsStore.useValue() || []
41 | const filteredPlayLists = useMemo(() => {
42 | const playLists = [favoritePlayListItem, ...storedPlayLists]
43 |
44 | if (!search) return playLists
45 |
46 | return playLists.filter((playlist: Playlist) =>
47 | playlist.name.toLowerCase().includes(search.toLowerCase()),
48 | )
49 | }, [search, favoritePlayListItem, storedPlayLists])
50 | return (
51 |
57 | No playlist found
58 |
59 |
63 |
64 | }
65 | data={filteredPlayLists}
66 | renderItem={({ item: playlist }) => (
67 | handlePlaylistPress(playlist as IMusic.PlayList)}
70 | />
71 | )}
72 | {...flatListProps}
73 | />
74 | )
75 | }
76 |
--------------------------------------------------------------------------------
/src/app/(tabs)/(songs)/index.tsx:
--------------------------------------------------------------------------------
1 | import { TracksList } from '@/components/TracksList'
2 | import { screenPadding } from '@/constants/tokens'
3 | import { trackTitleFilter } from '@/helpers/filter'
4 | import { generateTracksListId } from '@/helpers/miscellaneous'
5 | import { songsNumsToLoadStore } from '@/helpers/trackPlayerIndex'
6 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
7 | import { useLibraryStore, useTracks, useTracksLoading } from '@/store/library'
8 | import { defaultStyles } from '@/styles'
9 | import i18n from '@/utils/i18n'
10 | import { useMemo } from 'react'
11 | import { ActivityIndicator, ScrollView, View } from 'react-native'
12 | const SongsScreen = () => {
13 | const search = useNavigationSearch({
14 | searchBarOptions: {
15 | placeholder: i18n.t('find.inSongs'),
16 | cancelButtonText: i18n.t('find.cancel'),
17 | },
18 | })
19 |
20 | const tracks = useTracks()
21 | const songsNumsToLoad = songsNumsToLoadStore.useValue()
22 | const isLoading = useTracksLoading() // 添加加载状态
23 | const { fetchTracks } = useLibraryStore()
24 | const filteredTracks = useMemo(() => {
25 | if (!search) return tracks
26 | return tracks.filter(trackTitleFilter(search))
27 | }, [search, tracks])
28 | const handleLoadMore = () => {
29 | fetchTracks()
30 | }
31 |
32 | if (!tracks.length && isLoading) {
33 | return (
34 |
35 |
36 |
37 | )
38 | }
39 | return (
40 |
41 | {
45 | const { layoutMeasurement, contentOffset, contentSize } = nativeEvent
46 | const paddingToBottom = 20
47 | const isCloseToBottom =
48 | layoutMeasurement.height + contentOffset.y >= contentSize.height - paddingToBottom
49 |
50 | if (isCloseToBottom) {
51 | handleLoadMore()
52 | }
53 | }}
54 | scrollEventThrottle={400}
55 | >
56 |
62 | {/* {isLoading && tracks.length > 0 && (
63 |
70 |
71 |
72 | )} */}
73 |
74 |
75 | )
76 | }
77 |
78 | export default SongsScreen
79 |
--------------------------------------------------------------------------------
/android/app/src/main/AndroidManifest.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
--------------------------------------------------------------------------------
/android/app/src/main/java/com/music/player/gyc/MainApplication.kt:
--------------------------------------------------------------------------------
1 | package com.music.player.gyc
2 |
3 | import android.app.Application
4 | import android.content.res.Configuration
5 | import androidx.annotation.NonNull
6 |
7 | import com.facebook.react.PackageList
8 | import com.facebook.react.ReactApplication
9 | import com.facebook.react.ReactNativeHost
10 | import com.facebook.react.ReactPackage
11 | import com.facebook.react.ReactHost
12 | import com.facebook.react.config.ReactFeatureFlags
13 | import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load
14 | import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost
15 | import com.facebook.react.defaults.DefaultReactNativeHost
16 | import com.facebook.react.flipper.ReactNativeFlipper
17 | import com.facebook.soloader.SoLoader
18 |
19 | import expo.modules.ApplicationLifecycleDispatcher
20 | import expo.modules.ReactNativeHostWrapper
21 |
22 | class MainApplication : Application(), ReactApplication {
23 |
24 | override val reactNativeHost: ReactNativeHost = ReactNativeHostWrapper(
25 | this,
26 | object : DefaultReactNativeHost(this) {
27 | override fun getPackages(): List {
28 | // Packages that cannot be autolinked yet can be added manually here, for example:
29 | // packages.add(new MyReactNativePackage());
30 | return PackageList(this).packages
31 | }
32 |
33 | override fun getJSMainModuleName(): String = ".expo/.virtual-metro-entry"
34 |
35 | override fun getUseDeveloperSupport(): Boolean = BuildConfig.DEBUG
36 |
37 | override val isNewArchEnabled: Boolean = BuildConfig.IS_NEW_ARCHITECTURE_ENABLED
38 | override val isHermesEnabled: Boolean = BuildConfig.IS_HERMES_ENABLED
39 | }
40 | )
41 |
42 | override val reactHost: ReactHost
43 | get() = getDefaultReactHost(this.applicationContext, reactNativeHost)
44 |
45 | override fun onCreate() {
46 | super.onCreate()
47 | SoLoader.init(this, false)
48 | if (!BuildConfig.REACT_NATIVE_UNSTABLE_USE_RUNTIME_SCHEDULER_ALWAYS) {
49 | ReactFeatureFlags.unstable_useRuntimeSchedulerAlways = false
50 | }
51 | if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) {
52 | // If you opted-in for the New Architecture, we load the native entry point for this app.
53 | load()
54 | }
55 | if (BuildConfig.DEBUG) {
56 | ReactNativeFlipper.initializeFlipper(this, reactNativeHost.reactInstanceManager)
57 | }
58 | ApplicationLifecycleDispatcher.onApplicationCreate(this)
59 | }
60 |
61 | override fun onConfigurationChanged(newConfig: Configuration) {
62 | super.onConfigurationChanged(newConfig)
63 | ApplicationLifecycleDispatcher.onConfigurationChanged(this, newConfig)
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/src/components/PlaylistsList.tsx:
--------------------------------------------------------------------------------
1 | import { PlaylistListItem } from '@/components/PlaylistListItem'
2 | import { unknownTrackImageUri } from '@/constants/images'
3 | import myTrackPlayer from '@/helpers/trackPlayerIndex'
4 | import { Playlist } from '@/helpers/types'
5 | import { useNavigationSearch } from '@/hooks/useNavigationSearch'
6 | import { utilsStyles } from '@/styles'
7 | import i18n from '@/utils/i18n'
8 | import { useMemo } from 'react'
9 | import { Alert, FlatList, FlatListProps, Text, View } from 'react-native'
10 | import FastImage from 'react-native-fast-image'
11 | type PlaylistsListProps = {
12 | playlists: Playlist[]
13 | onPlaylistPress: (playlist: Playlist) => void
14 | } & Partial>
15 |
16 | const ItemDivider = () => (
17 |
18 | )
19 |
20 | export const PlaylistsList = ({
21 | playlists,
22 | onPlaylistPress: handlePlaylistPress,
23 | ...flatListProps
24 | }: PlaylistsListProps) => {
25 | const search = useNavigationSearch({
26 | searchBarOptions: {
27 | placeholder: i18n.t('find.inPlaylist'),
28 | cancelButtonText: i18n.t('find.cancel'),
29 | },
30 | })
31 |
32 | const filteredPlaylist = useMemo(() => {
33 | return playlists
34 | }, [playlists, search])
35 |
36 | const showDeleteAlert = (playlist: Playlist) => {
37 | Alert.alert('删除歌单', `确定要删除这个歌单吗 "${playlist.name}"?`, [
38 | { text: '取消', style: 'cancel' },
39 | {
40 | text: '删除',
41 | style: 'destructive',
42 | onPress: async () => {
43 | try {
44 | const result = await myTrackPlayer.deletePlayLists(playlist.id)
45 | if (result === 'success') {
46 | // 删除成功
47 | // 可以在这里添加一些成功的反馈,比如显示一个成功的提示
48 | Alert.alert('成功', '歌单删除成功')
49 | } else {
50 | // 删除失败,显示错误信息
51 | Alert.alert('错误', result)
52 | }
53 | } catch (error) {
54 | // 处理可能发生的错误
55 | Alert.alert('错误', 'An error occurred while deleting the playlist')
56 | }
57 | },
58 | },
59 | ])
60 | }
61 | return (
62 |
68 | No playlist found
69 |
70 |
74 |
75 | }
76 | data={playlists}
77 | renderItem={({ item: playlist }) => (
78 | handlePlaylistPress(playlist)}
81 | onLongPress={() => showDeleteAlert(playlist)}
82 | />
83 | )}
84 | {...flatListProps}
85 | />
86 | )
87 | }
88 |
--------------------------------------------------------------------------------
/ios/CyMusic/AppDelegate.mm:
--------------------------------------------------------------------------------
1 | #import "AppDelegate.h"
2 |
3 | #import
4 | #import
5 |
6 | @implementation AppDelegate
7 |
8 | - (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
9 | {
10 | self.moduleName = @"main";
11 |
12 | // You can add your custom initial props in the dictionary below.
13 | // They will be passed down to the ViewController used by React Native.
14 | self.initialProps = @{};
15 |
16 | return [super application:application didFinishLaunchingWithOptions:launchOptions];
17 | }
18 |
19 | - (NSURL *)sourceURLForBridge:(RCTBridge *)bridge
20 | {
21 | return [self getBundleURL];
22 | }
23 |
24 | - (NSURL *)getBundleURL
25 | {
26 | #if DEBUG
27 | return [[RCTBundleURLProvider sharedSettings] jsBundleURLForBundleRoot:@".expo/.virtual-metro-entry"];
28 | #else
29 | return [[NSBundle mainBundle] URLForResource:@"main" withExtension:@"jsbundle"];
30 | #endif
31 | }
32 |
33 | // Linking API
34 | - (BOOL)application:(UIApplication *)application openURL:(NSURL *)url options:(NSDictionary *)options {
35 | return [super application:application openURL:url options:options] || [RCTLinkingManager application:application openURL:url options:options];
36 | }
37 |
38 | // Universal Links
39 | - (BOOL)application:(UIApplication *)application continueUserActivity:(nonnull NSUserActivity *)userActivity restorationHandler:(nonnull void (^)(NSArray> * _Nullable))restorationHandler {
40 | BOOL result = [RCTLinkingManager application:application continueUserActivity:userActivity restorationHandler:restorationHandler];
41 | return [super application:application continueUserActivity:userActivity restorationHandler:restorationHandler] || result;
42 | }
43 |
44 | // Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
45 | - (void)application:(UIApplication *)application didRegisterForRemoteNotificationsWithDeviceToken:(NSData *)deviceToken
46 | {
47 | return [super application:application didRegisterForRemoteNotificationsWithDeviceToken:deviceToken];
48 | }
49 |
50 | // Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
51 | - (void)application:(UIApplication *)application didFailToRegisterForRemoteNotificationsWithError:(NSError *)error
52 | {
53 | return [super application:application didFailToRegisterForRemoteNotificationsWithError:error];
54 | }
55 |
56 | // Explicitly define remote notification delegates to ensure compatibility with some third-party libraries
57 | - (void)application:(UIApplication *)application didReceiveRemoteNotification:(NSDictionary *)userInfo fetchCompletionHandler:(void (^)(UIBackgroundFetchResult))completionHandler
58 | {
59 | return [super application:application didReceiveRemoteNotification:userInfo fetchCompletionHandler:completionHandler];
60 | }
61 |
62 | @end
63 |
--------------------------------------------------------------------------------
/src/components/utils/musicSdk/tx/musicInfo.js:
--------------------------------------------------------------------------------
1 | import { httpFetch } from '../../request'
2 | import { formatPlayTime, sizeFormate } from '../../index'
3 |
4 | const getSinger = (singers) => {
5 | let arr = []
6 | singers.forEach(singer => {
7 | arr.push(singer.name)
8 | })
9 | return arr.join('、')
10 | }
11 |
12 | export default (songmid) => {
13 | const requestObj = httpFetch('https://u.y.qq.com/cgi-bin/musicu.fcg', {
14 | method: 'post',
15 | headers: {
16 | 'User-Agent': 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; WOW64; Trident/5.0)',
17 | },
18 | body: {
19 | comm: {
20 | ct: '19',
21 | cv: '1859',
22 | uin: '0',
23 | },
24 | req: {
25 | module: 'music.pf_song_detail_svr',
26 | method: 'get_song_detail_yqq',
27 | param: {
28 | song_type: 0,
29 | song_mid: songmid,
30 | },
31 | },
32 | },
33 | })
34 | return requestObj.promise.then(({ body }) => {
35 | // console.log(body)
36 | if (body.code != 0 || body.req.code != 0) return Promise.reject(new Error('获取歌曲信息失败'))
37 | const item = body.req.data.track_info
38 | if (!item.file?.media_mid) return null
39 |
40 | let types = []
41 | let _types = {}
42 | const file = item.file
43 | if (file.size_128mp3 != 0) {
44 | let size = sizeFormate(file.size_128mp3)
45 | types.push({ type: '128k', size })
46 | _types['128k'] = {
47 | size,
48 | }
49 | }
50 | if (file.size_320mp3 !== 0) {
51 | let size = sizeFormate(file.size_320mp3)
52 | types.push({ type: '320k', size })
53 | _types['320k'] = {
54 | size,
55 | }
56 | }
57 | if (file.size_flac !== 0) {
58 | let size = sizeFormate(file.size_flac)
59 | types.push({ type: 'flac', size })
60 | _types.flac = {
61 | size,
62 | }
63 | }
64 | if (file.size_hires !== 0) {
65 | let size = sizeFormate(file.size_hires)
66 | types.push({ type: 'flac24bit', size })
67 | _types.flac24bit = {
68 | size,
69 | }
70 | }
71 | // types.reverse()
72 | let albumId = ''
73 | let albumName = ''
74 | if (item.album) {
75 | albumName = item.album.name
76 | albumId = item.album.mid
77 | }
78 | return {
79 | singer: getSinger(item.singer),
80 | name: item.title,
81 | albumName,
82 | albumId,
83 | source: 'tx',
84 | interval: formatPlayTime(item.interval),
85 | songId: item.id,
86 | albumMid: item.album?.mid ?? '',
87 | strMediaMid: item.file.media_mid,
88 | songmid: item.mid,
89 | img: (albumId === '' || albumId === '空')
90 | ? item.singer?.length ? `https://y.gtimg.cn/music/photo_new/T001R500x500M000${item.singer[0].mid}.jpg` : ''
91 | : `https://y.gtimg.cn/music/photo_new/T002R500x500M000${albumId}.jpg`,
92 | types,
93 | _types,
94 | typeUrl: {},
95 | }
96 | })
97 | }
98 |
99 |
--------------------------------------------------------------------------------
/src/app/(modals)/[name].tsx:
--------------------------------------------------------------------------------
1 | import { SingerTracksList } from '@/components/SingerTracksList'
2 | import { colors, screenPadding } from '@/constants/tokens'
3 | import { logInfo } from '@/helpers/logger'
4 | import { getAlbumSongList, getSingerDetail } from '@/helpers/userApi/getMusicSource'
5 | import { defaultStyles } from '@/styles'
6 | import { useLocalSearchParams, usePathname } from 'expo-router'
7 | import React, { useEffect, useState } from 'react'
8 | import { ActivityIndicator, ScrollView, View } from 'react-native'
9 | import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
10 | import { Track } from 'react-native-track-player'
11 | import ShareIntent from './shareintent'
12 | // 专辑页面or歌手页面
13 | const SingerListScreen = () => {
14 | const pathname = usePathname()
15 | logInfo('pathname', pathname)
16 |
17 | const { name: playlistName, album } = useLocalSearchParams<{ name: string; album?: string }>()
18 | const isAlbum = !!album
19 | logInfo('album', album)
20 |
21 | const [singerListDetail, setSingerListDetail] = useState<{ musicList: Track[] } | null>(null)
22 | const [loading, setLoading] = useState(true)
23 |
24 | useEffect(() => {
25 | const fetchSingerListDetail = async () => {
26 | let detail
27 | if (isAlbum) {
28 | detail = await getAlbumSongList(playlistName)
29 | // console.log('detail', detail)
30 | // console.log('playlistName', playlistName)
31 | } else {
32 | detail = await getSingerDetail(playlistName)
33 | }
34 |
35 | setSingerListDetail(detail)
36 |
37 | setLoading(false)
38 | }
39 | fetchSingerListDetail()
40 | }, [])
41 |
42 | if (pathname.includes('cymusic')) {
43 | return
44 | }
45 |
46 | console.log('album', album)
47 |
48 | if (loading) {
49 | return (
50 |
58 |
59 |
60 | )
61 | }
62 | const DismissPlayerSymbol = () => {
63 | const { top } = useSafeAreaInsets()
64 |
65 | return (
66 |
76 |
86 |
87 | )
88 | }
89 |
90 | return (
91 |
92 |
93 |
97 |
98 |
99 |
100 | )
101 | }
102 |
103 | export default SingerListScreen
104 |
--------------------------------------------------------------------------------
/ios/CyMusic/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CADisableMinimumFrameDurationOnPhone
6 |
7 | CFBundleDevelopmentRegion
8 | $(DEVELOPMENT_LANGUAGE)
9 | CFBundleDisplayName
10 | CyMusic
11 | CFBundleExecutable
12 | $(EXECUTABLE_NAME)
13 | CFBundleIdentifier
14 | $(PRODUCT_BUNDLE_IDENTIFIER)
15 | CFBundleInfoDictionaryVersion
16 | 6.0
17 | CFBundleName
18 | $(PRODUCT_NAME)
19 | CFBundlePackageType
20 | $(PRODUCT_BUNDLE_PACKAGE_TYPE)
21 | CFBundleShortVersionString
22 | 1.1.7
23 | CFBundleSignature
24 | ????
25 | CFBundleURLTypes
26 |
27 |
28 | CFBundleURLSchemes
29 |
30 | cymusic
31 | com.music.player.gyc
32 |
33 |
34 |
35 | CFBundleURLSchemes
36 |
37 | exp+cymusic
38 |
39 |
40 |
41 | CFBundleVersion
42 | 1
43 | LSRequiresIPhoneOS
44 |
45 | LSSupportsOpeningDocumentsInPlace
46 |
47 | NSAppTransportSecurity
48 |
49 | NSAllowsArbitraryLoads
50 |
51 |
52 | NSCameraUsageDescription
53 | Allow $(PRODUCT_NAME) to access your camera
54 | NSMicrophoneUsageDescription
55 | Allow $(PRODUCT_NAME) to access your microphone
56 | NSPhotoLibraryUsageDescription
57 | Allow $(PRODUCT_NAME) to access your photos
58 | NSUserActivityTypes
59 |
60 | $(PRODUCT_BUNDLE_IDENTIFIER).expo.index_route
61 |
62 | UIBackgroundModes
63 |
64 | audio
65 |
66 | UIFileSharingEnabled
67 |
68 | UILaunchStoryboardName
69 | SplashScreen
70 | UIRequiredDeviceCapabilities
71 |
72 | armv7
73 |
74 | UIRequiresFullScreen
75 |
76 | UIStatusBarStyle
77 | UIStatusBarStyleDefault
78 | UISupportedInterfaceOrientations
79 |
80 | UIInterfaceOrientationPortrait
81 | UIInterfaceOrientationPortraitUpsideDown
82 |
83 | UISupportedInterfaceOrientations~ipad
84 |
85 | UIInterfaceOrientationPortrait
86 | UIInterfaceOrientationPortraitUpsideDown
87 | UIInterfaceOrientationLandscapeLeft
88 | UIInterfaceOrientationLandscapeRight
89 |
90 | UIUserInterfaceStyle
91 | Dark
92 | UIViewControllerBasedStatusBarAppearance
93 |
94 |
95 |
96 |
--------------------------------------------------------------------------------
/src/utils/mediaItem.ts:
--------------------------------------------------------------------------------
1 | import { internalSerializeKey, sortIndexSymbol, timeStampSymbol } from '@/constants/commonConst'
2 | import MediaMeta from '@/store/mediaExtra'
3 |
4 | /** 获取mediakey */
5 | export function getMediaKey(mediaItem: ICommon.IMediaBase) {
6 | return `${mediaItem.platform}@${mediaItem.id}`
7 | }
8 |
9 | /** 比较两media是否相同 */
10 | export function isSameMediaItem(
11 | a: ICommon.IMediaBase | null | undefined,
12 | b: ICommon.IMediaBase | null | undefined,
13 | ) {
14 | return a && b && a.id == b.id && a.platform === b.platform
15 | }
16 |
17 | /** 查找是否存在 */
18 | export function includesMedia(
19 | a: ICommon.IMediaBase[] | null | undefined,
20 | b: ICommon.IMediaBase | null | undefined,
21 | ) {
22 | if (!a || !b) {
23 | return false
24 | }
25 | return a.findIndex((_) => isSameMediaItem(_, b)) !== -1
26 | }
27 |
28 | /** 获取复位的mediaItem */
29 | // export function resetMediaItem>(
30 | // mediaItem: T,
31 | // platform?: string,
32 | // newObj?: boolean,
33 | // ): T {
34 | // // 本地音乐不做处理
35 | // if (
36 | // mediaItem.platform === localPluginPlatform ||
37 | // platform === localPluginPlatform
38 | // ) {
39 | // return newObj ? {...mediaItem} : mediaItem;
40 | // }
41 | // if (!newObj) {
42 | // mediaItem.platform = platform ?? mediaItem.platform;
43 | // mediaItem[internalSerializeKey] = undefined;
44 | // return mediaItem;
45 | // } else {
46 | // return produce(mediaItem, _ => {
47 | // _.platform = platform ?? mediaItem.platform;
48 | // _[internalSerializeKey] = undefined;
49 | // });
50 | // }
51 | // }
52 |
53 | export function mergeProps(
54 | mediaItem: ICommon.IMediaBase,
55 | props: Record | undefined,
56 | anotherProps?: Record | undefined | null,
57 | ) {
58 | return props
59 | ? {
60 | ...mediaItem,
61 | ...props,
62 | ...(anotherProps ?? {}),
63 | id: mediaItem.id,
64 | platform: mediaItem.platform,
65 | }
66 | : mediaItem
67 | }
68 |
69 | export enum InternalDataType {
70 | LOCALPATH = 'localPath',
71 | }
72 |
73 | export function trimInternalData(mediaItem: ICommon.IMediaBase | null | undefined) {
74 | if (!mediaItem) {
75 | return undefined
76 | }
77 | return {
78 | ...mediaItem,
79 | [internalSerializeKey]: undefined,
80 | }
81 | }
82 |
83 | /** 关联歌词 */
84 | export async function associateLrc(musicItem: ICommon.IMediaBase, linkto: ICommon.IMediaBase) {
85 | if (!musicItem || !linkto) {
86 | throw new Error('')
87 | }
88 |
89 | // 如果相同直接断链
90 | MediaMeta.update(musicItem, {
91 | associatedLrc: isSameMediaItem(musicItem, linkto) ? undefined : linkto,
92 | })
93 | }
94 |
95 | export function sortByTimestampAndIndex(array: any[], newArray = false) {
96 | if (newArray) {
97 | array = [...array]
98 | }
99 | return array.sort((a, b) => {
100 | const ts = a[timeStampSymbol] - b[timeStampSymbol]
101 | if (ts !== 0) {
102 | return ts
103 | }
104 | return a[sortIndexSymbol] - b[sortIndexSymbol]
105 | })
106 | }
107 |
--------------------------------------------------------------------------------
/android/gradlew.bat:
--------------------------------------------------------------------------------
1 | @rem
2 | @rem Copyright 2015 the original author or authors.
3 | @rem
4 | @rem Licensed under the Apache License, Version 2.0 (the "License");
5 | @rem you may not use this file except in compliance with the License.
6 | @rem You may obtain a copy of the License at
7 | @rem
8 | @rem https://www.apache.org/licenses/LICENSE-2.0
9 | @rem
10 | @rem Unless required by applicable law or agreed to in writing, software
11 | @rem distributed under the License is distributed on an "AS IS" BASIS,
12 | @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 | @rem See the License for the specific language governing permissions and
14 | @rem limitations under the License.
15 | @rem
16 |
17 | @if "%DEBUG%"=="" @echo off
18 | @rem ##########################################################################
19 | @rem
20 | @rem Gradle startup script for Windows
21 | @rem
22 | @rem ##########################################################################
23 |
24 | @rem Set local scope for the variables with windows NT shell
25 | if "%OS%"=="Windows_NT" setlocal
26 |
27 | set DIRNAME=%~dp0
28 | if "%DIRNAME%"=="" set DIRNAME=.
29 | set APP_BASE_NAME=%~n0
30 | set APP_HOME=%DIRNAME%
31 |
32 | @rem Resolve any "." and ".." in APP_HOME to make it shorter.
33 | for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
34 |
35 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
36 | set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
37 |
38 | @rem Find java.exe
39 | if defined JAVA_HOME goto findJavaFromJavaHome
40 |
41 | set JAVA_EXE=java.exe
42 | %JAVA_EXE% -version >NUL 2>&1
43 | if %ERRORLEVEL% equ 0 goto execute
44 |
45 | echo.
46 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
47 | echo.
48 | echo Please set the JAVA_HOME variable in your environment to match the
49 | echo location of your Java installation.
50 |
51 | goto fail
52 |
53 | :findJavaFromJavaHome
54 | set JAVA_HOME=%JAVA_HOME:"=%
55 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe
56 |
57 | if exist "%JAVA_EXE%" goto execute
58 |
59 | echo.
60 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
61 | echo.
62 | echo Please set the JAVA_HOME variable in your environment to match the
63 | echo location of your Java installation.
64 |
65 | goto fail
66 |
67 | :execute
68 | @rem Setup the command line
69 |
70 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
71 |
72 |
73 | @rem Execute Gradle
74 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
75 |
76 | :end
77 | @rem End local scope for the variables with windows NT shell
78 | if %ERRORLEVEL% equ 0 goto mainEnd
79 |
80 | :fail
81 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
82 | rem the _cmd.exe /c_ return code!
83 | set EXIT_CODE=%ERRORLEVEL%
84 | if %EXIT_CODE% equ 0 set EXIT_CODE=1
85 | if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
86 | exit /b %EXIT_CODE%
87 |
88 | :mainEnd
89 | if "%OS%"=="Windows_NT" endlocal
90 |
91 | :omega
92 |
--------------------------------------------------------------------------------
/src/components/utils/nativeModules/userApi.ts:
--------------------------------------------------------------------------------
1 | import { NativeEventEmitter, NativeModules } from 'react-native'
2 |
3 | const { UserApiModule } = NativeModules
4 |
5 | let loadScriptInfo: LX.UserApi.UserApiInfo | null = null
6 | export const loadScript = (info: LX.UserApi.UserApiInfo & { script: string }) => {
7 | loadScriptInfo = info
8 | UserApiModule.loadScript({
9 | id: info.id,
10 | name: info.name,
11 | description: info.description,
12 | version: info.version ?? '',
13 | author: info.author ?? '',
14 | homepage: info.homepage ?? '',
15 | script: info.script,
16 | })
17 | }
18 |
19 | export interface SendResponseParams {
20 | requestKey: string
21 | error: string | null
22 | response: {
23 | statusCode: number
24 | statusMessage: string
25 | headers: Record
26 | body: any
27 | } | null
28 | }
29 | export interface SendActions {
30 | request: LX.UserApi.UserApiRequestParams
31 | response: SendResponseParams
32 | }
33 | export const sendAction = (action: T, data: SendActions[T]) => {
34 | UserApiModule.sendAction(action, JSON.stringify(data))
35 | }
36 |
37 | // export const clearAppCache = CacheModule.clearAppCache as () => Promise
38 |
39 | export interface InitParams {
40 | status: boolean
41 | errorMessage: string
42 | info: LX.UserApi.UserApiInfo
43 | }
44 |
45 | export interface ResponseParams {
46 | status: boolean
47 | errorMessage?: string
48 | requestKey: string
49 | result: any
50 | }
51 | export interface UpdateInfoParams {
52 | name: string
53 | log: string
54 | updateUrl: string
55 | }
56 | export interface RequestParams {
57 | requestKey: string
58 | url: string
59 | options: {
60 | method: string
61 | data: any
62 | timeout: number
63 | headers: any
64 | binary: boolean
65 | }
66 | }
67 | export type CancelRequestParams = string
68 |
69 | export interface Actions {
70 | init: InitParams
71 | request: RequestParams
72 | cancelRequest: CancelRequestParams
73 | response: ResponseParams
74 | showUpdateAlert: UpdateInfoParams
75 | log: string
76 | }
77 | export type ActionsEvent = { [K in keyof Actions]: { action: K, data: Actions[K] } }[keyof Actions]
78 |
79 | export const onScriptAction = (handler: (event: ActionsEvent) => void): () => void => {
80 | // eslint-disable-next-line @typescript-eslint/no-unsafe-argument
81 | const eventEmitter = new NativeEventEmitter(UserApiModule)
82 | const eventListener = eventEmitter.addListener('api-action', event => {
83 | if (event.data) event.data = JSON.parse(event.data as string)
84 | if (event.action == 'init') {
85 | if (event.data.info) event.data.info = { ...loadScriptInfo, ...event.data.info }
86 | else event.data.info = { ...loadScriptInfo }
87 | } else if (event.action == 'showUpdateAlert') {
88 | if (!loadScriptInfo?.allowShowUpdateAlert) return
89 | }
90 | handler(event as ActionsEvent)
91 | })
92 |
93 | return () => {
94 | eventListener.remove()
95 | }
96 | }
97 |
98 | export const destroy = () => {
99 | UserApiModule.destroy()
100 | }
101 |
--------------------------------------------------------------------------------
/src/components/PlayerVolumeBar.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import { utilsStyles } from '@/styles'
3 | import { Ionicons } from '@expo/vector-icons'
4 | import React, { useEffect, useState } from 'react'
5 | import { View, ViewProps } from 'react-native'
6 | import { Slider } from 'react-native-awesome-slider'
7 | import Animated, { useAnimatedStyle, useSharedValue, withSpring } from 'react-native-reanimated'
8 | import { VolumeManager } from 'react-native-volume-manager'
9 |
10 | const NORMAL_HEIGHT = 4
11 | const EXPANDED_HEIGHT = 4
12 |
13 | export const PlayerVolumeBar = ({ style }: ViewProps) => {
14 | const [volume, setVolume] = useState(0)
15 | const progress = useSharedValue(0)
16 | const min = useSharedValue(0)
17 | const max = useSharedValue(1)
18 | const isSliding = useSharedValue(false)
19 |
20 | const [trackColor, setTrackColor] = useState(colors.maximumTrackTintColor)
21 |
22 | useEffect(() => {
23 | const getInitialVolume = async () => {
24 | await VolumeManager.showNativeVolumeUI({ enabled: true })
25 | const initialVolume = await VolumeManager.getVolume()
26 | setVolume(initialVolume.volume)
27 | progress.value = initialVolume.volume
28 | }
29 | getInitialVolume()
30 |
31 | const volumeListener = VolumeManager.addVolumeListener((result) => {
32 | setVolume(result.volume)
33 | progress.value = result.volume
34 | })
35 |
36 | return () => {
37 | volumeListener.remove()
38 | }
39 | }, [])
40 |
41 | const animatedSliderStyle = useAnimatedStyle(() => {
42 | return {
43 | height: withSpring(isSliding.value ? EXPANDED_HEIGHT : NORMAL_HEIGHT),
44 | transform: [{ scaleY: withSpring(isSliding.value ? 2 : 1) }],
45 | }
46 | })
47 |
48 | return (
49 |
50 |
51 |
52 |
53 |
56 | {
61 | isSliding.value = true
62 | setTrackColor('#fff')
63 | }}
64 | onSlidingComplete={() => {
65 | isSliding.value = false
66 | setTrackColor(colors.maximumTrackTintColor)
67 | }}
68 | onValueChange={async (value) => {
69 | await VolumeManager.showNativeVolumeUI({ enabled: true })
70 | await VolumeManager.setVolume(value, {
71 | type: 'system', // default: "music" (Android only)
72 | showUI: true, // default: false (suppress native UI volume toast for iOS & Android)
73 | playSound: false, // default: false (Android only)
74 | })
75 | }}
76 | renderBubble={() => null}
77 | theme={{
78 | minimumTrackTintColor: trackColor,
79 | maximumTrackTintColor: colors.maximumTrackTintColor,
80 | }}
81 | thumbWidth={0}
82 | maximumValue={max}
83 | />
84 |
85 |
86 |
87 |
88 |
89 | )
90 | }
91 |
--------------------------------------------------------------------------------
/src/components/lyric/lyricItem.tsx:
--------------------------------------------------------------------------------
1 | import { colors } from '@/constants/tokens'
2 | import rpx from '@/utils/rpx'
3 | import * as Haptics from 'expo-haptics'
4 | import React, { memo, useCallback, useRef } from 'react'
5 | import { Animated, StyleSheet, Text, TouchableWithoutFeedback, View } from 'react-native'
6 | interface ILyricItemComponentProps {
7 | // 行号
8 | index?: number
9 | // 显示
10 | light?: boolean
11 | // 高亮
12 | highlight?: boolean
13 | // 文本
14 | text?: string
15 | // 字体大小
16 | fontSize?: number
17 | onPress: () => Promise
18 | onLayout?: (index: number, height: number) => void
19 | }
20 |
21 | function _LyricItemComponent(props: ILyricItemComponentProps) {
22 | const { light, highlight, text, onLayout, index, fontSize, onPress } = props
23 | const animatedOpacity = useRef(new Animated.Value(0)).current
24 |
25 | const handlePress = useCallback(() => {
26 | // 触发震动
27 | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
28 |
29 | // 显示背景
30 | Animated.sequence([
31 | Animated.timing(animatedOpacity, {
32 | toValue: 1,
33 | duration: 600,
34 | useNativeDriver: true,
35 | }),
36 | Animated.timing(animatedOpacity, {
37 | toValue: 0,
38 | duration: 200,
39 | useNativeDriver: true,
40 | }),
41 | ]).start()
42 |
43 | // 调用原来的 onPress
44 | onPress()
45 | }, [onPress, animatedOpacity])
46 |
47 | return (
48 |
49 |
50 |
58 | {
60 | if (index !== undefined) {
61 | onLayout?.(index, nativeEvent.layout.height)
62 | }
63 | }}
64 | style={[
65 | lyricStyles.item,
66 | {
67 | fontSize: fontSize || rpx(28),
68 | },
69 | highlight
70 | ? [
71 | lyricStyles.highlightItem,
72 | {
73 | color: colors.primary,
74 | },
75 | ]
76 | : null,
77 | // light ? lyricStyles.draggingItem : null,
78 | ]}
79 | >
80 | {text}
81 |
82 |
83 |
84 | )
85 | }
86 | // 歌词
87 | const LyricItemComponent = memo(
88 | _LyricItemComponent,
89 | (prev, curr) =>
90 | prev.light === curr.light &&
91 | prev.highlight === curr.highlight &&
92 | prev.text === curr.text &&
93 | prev.index === curr.index &&
94 | prev.fontSize === curr.fontSize,
95 | )
96 |
97 | export default LyricItemComponent
98 |
99 | const lyricStyles = StyleSheet.create({
100 | highlightItem: {
101 | opacity: 1,
102 | },
103 | item: {
104 | color: 'white',
105 | opacity: 0.6,
106 | paddingHorizontal: rpx(64),
107 | paddingVertical: rpx(24),
108 | width: '100%',
109 | textAlign: 'center',
110 | textAlignVertical: 'center',
111 | },
112 | draggingItem: {
113 | opacity: 1,
114 | color: 'white',
115 | },
116 | background: {
117 | position: 'absolute',
118 | top: 0,
119 | left: 0,
120 | right: 0,
121 | bottom: 0,
122 | backgroundColor: 'rgba(255, 255, 255, 0.2)',
123 | borderRadius: rpx(10),
124 | },
125 | })
126 |
--------------------------------------------------------------------------------