diff --git a/assets/screenshot2.jpg b/assets/screenshot2.jpg index cd93b11..a750793 100644 Binary files a/assets/screenshot2.jpg and b/assets/screenshot2.jpg differ diff --git a/package.json b/package.json index a017c46..ed24d04 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sdh-gamethememusic", - "version": "1.1.0", + "version": "1.2.0", "description": "Play theme songs on your game pages", "scripts": { "build": "shx rm -rf dist && rollup -c", diff --git a/src/actions/audio.ts b/src/actions/audio.ts index 3f5ed9e..259ab29 100644 --- a/src/actions/audio.ts +++ b/src/actions/audio.ts @@ -1,4 +1,5 @@ import { ServerAPI } from 'decky-frontend-lib' +import YouTubeVideo from '../../types/YouTube' type YouTubeInitialData = { contents: { @@ -11,6 +12,9 @@ type YouTubeInitialData = { videoRenderer: { title: { runs: { text: string }[] } videoId: string + thumbnail: { + thumbnails: { url: string }[] + } } }[] } @@ -23,13 +27,16 @@ type YouTubeInitialData = { export async function getYouTubeSearchResults( serverAPI: ServerAPI, - appName: string -): Promise<{ appName: string; title: string; id: string }[] | undefined> { + appName: string, + customSearch?: boolean +): Promise { + const searchTerm = `${encodeURIComponent(appName)}${ + customSearch ? '' : '%20Theme%20Music' + }` const req = { method: 'GET', - url: `https://www.youtube.com/results?search_query=${encodeURIComponent( - appName - )}%20Theme%20Music` + url: `https://www.youtube.com/results?search_query=${searchTerm}&sp=EgIQAQ%253D%253D`, + timeout: 8000 } const res = await serverAPI.callServerMethod< { method: string; url: string }, @@ -41,9 +48,7 @@ export async function getYouTubeSearchResults( if (match) { const ytInitialData: YouTubeInitialData = JSON.parse(match[1]) - const results: - | { appName: string; title: string; id: string }[] - | undefined = + const results: YouTubeVideo[] | undefined = ytInitialData?.contents?.twoColumnSearchResultsRenderer?.primaryContents?.sectionListRenderer?.contents ?.find( (obj) => @@ -59,11 +64,14 @@ export async function getYouTubeSearchResults( ?.itemSectionRenderer?.contents?.filter((obj) => Object.prototype.hasOwnProperty.call(obj, 'videoRenderer') ) - .map((res) => ({ - appName, - title: res?.videoRenderer?.title?.runs?.[0]?.text, - id: res?.videoRenderer?.videoId - })) + .map((res) => { + return { + appName, + title: res?.videoRenderer?.title?.runs?.[0]?.text, + id: res?.videoRenderer?.videoId, + thumbnail: res?.videoRenderer?.thumbnail?.thumbnails?.[0]?.url + } + }) .filter((res: { title: string; id: string }) => res.id?.length) return results } else { @@ -75,15 +83,8 @@ export async function getYouTubeSearchResults( export async function getAudioUrlFromVideoId( serverAPI: ServerAPI, - video: { - appName: string - title: string - id: string - } -): Promise< - | { appName: string; title: string; videoId: string; audioUrl: string } - | undefined -> { + video: { title: string; id: string } +): Promise { const req = { method: 'GET', url: `https://www.youtube.com/watch?v=${encodeURIComponent(video.id)}` @@ -101,7 +102,7 @@ export async function getAudioUrlFromVideoId( } const configJson = JSON.parse(configJsonMatch[1]) - const streamMap = configJson.streamingData.adaptiveFormats.filter( + const streamMap = configJson?.streamingData?.adaptiveFormats?.filter( (f: { mimeType: string }) => f.mimeType.startsWith('audio/') )[0] if (!streamMap?.url) return undefined @@ -112,14 +113,9 @@ export async function getAudioUrlFromVideoId( .find((s: string) => s.startsWith('s=')) .substr(2) : undefined - return { - appName: video.appName, - title: video.title, - videoId: video.id, - audioUrl: `${streamMap.url}&${ - signature ? `sig=${signature}` : 'ratebypass=yes' - }` - } + return `${streamMap.url}&${ + signature ? `sig=${signature}` : 'ratebypass=yes' + }` } return undefined } @@ -127,19 +123,15 @@ export async function getAudioUrlFromVideoId( export async function getAudio( serverAPI: ServerAPI, appName: string -): Promise< - | { appName: string; title: string; videoId: string; audioUrl: string } - | undefined -> { +): Promise<{ videoId: string; audioUrl: string } | undefined> { const videos = await getYouTubeSearchResults(serverAPI, appName) if (videos?.length) { - let audio: - | { appName: string; title: string; videoId: string; audioUrl: string } - | undefined + let audio: { videoId: string; audioUrl: string } | undefined let i for (i = 0; i < videos.length; i++) { - audio = await getAudioUrlFromVideoId(serverAPI, videos[i]) - if (audio?.audioUrl?.length) { + const audioUrl = await getAudioUrlFromVideoId(serverAPI, videos[i]) + if (audioUrl?.length) { + audio = { audioUrl, videoId: videos[i].id } break } } diff --git a/src/cache/musicCache.ts b/src/cache/musicCache.ts index be486bb..eab0ac2 100644 --- a/src/cache/musicCache.ts +++ b/src/cache/musicCache.ts @@ -7,18 +7,11 @@ localforage.config({ }) type GameThemeMusicCache = { - appName: string - title: string - videoId: string - disabled?: boolean + videoId: string | undefined } export async function updateCache(appId: number, newData: GameThemeMusicCache) { - const oldCache = await localforage.getItem( - appId.toString() - ) - const newCache: GameThemeMusicCache = { ...oldCache, ...newData } - await localforage.setItem(appId.toString(), newCache) + const newCache = await localforage.setItem(appId.toString(), newData) return newCache } @@ -33,6 +26,6 @@ export function clearCache(appId?: number) { export async function getCache( appId: number ): Promise { - const data = await localforage.getItem(appId.toString()) - return data + const cache = await localforage.getItem(appId.toString()) + return cache } diff --git a/src/components/changeTheme/audioPlayer.tsx b/src/components/changeTheme/audioPlayer.tsx index 1d33e4c..efb1886 100644 --- a/src/components/changeTheme/audioPlayer.tsx +++ b/src/components/changeTheme/audioPlayer.tsx @@ -1,39 +1,44 @@ -import { - DialogButton, - Focusable, - PanelSection, - PanelSectionRow -} from 'decky-frontend-lib' -import React, { useEffect, useRef } from 'react' +import { DialogButton, Focusable, ServerAPI } from 'decky-frontend-lib' +import React, { useEffect, useRef, useState } from 'react' import useTranslations from '../../hooks/useTranslations' +import { getAudioUrlFromVideoId } from '../../actions/audio' +import YouTubeVideo from '../../../types/YouTube' export default function AudioPlayer({ - audio, - volume, handlePlay, selected, - selectNewAudio + selectNewAudio, + serverAPI, + video, + volume }: { - audio: { - appName: string - title: string - videoId: string - audioUrl: string - isPlaying: boolean - } + serverAPI: ServerAPI + video: YouTubeVideo & { isPlaying: boolean } volume: number handlePlay: (startPlaying: boolean) => void selected: boolean selectNewAudio: (audio: { - appName: string title: string - videoId: string + videoId: string | undefined audioUrl: string - disabled: false }) => void }) { const t = useTranslations() const audioRef = useRef(null) + const [loading, setLoading] = useState(true) + const [audioUrl, setAudio] = useState() + + useEffect(() => { + async function getData() { + setLoading(true) + const res = await getAudioUrlFromVideoId(serverAPI, video) + setAudio(res) + setLoading(false) + } + if (video.id.length) { + getData() + } + }, [video.id]) useEffect(() => { if (audioRef.current) { @@ -43,50 +48,97 @@ export default function AudioPlayer({ useEffect(() => { if (audioRef.current) { - audio.isPlaying ? audioRef.current.play() : audioRef.current.pause() + video.isPlaying ? audioRef.current.play() : audioRef.current.pause() } - }, [audio.isPlaying]) + }, [video.isPlaying]) function togglePlay() { if (audioRef?.current) { audioRef.current.currentTime = 0 - handlePlay(!audio.isPlaying) + handlePlay(!video.isPlaying) } } function selectAudio() { - selectNewAudio({ - appName: audio.appName, - title: audio.title, - videoId: audio.videoId, - audioUrl: audio.audioUrl, - disabled: false - }) + if (audioUrl?.length && video.id.length) + selectNewAudio({ + title: video.title, + videoId: video.id, + audioUrl: audioUrl + }) } + if (!loading && !audioUrl) return <> return ( - +
+ + {video.title} +

+ {video.title} +

+ +
+ + {video.isPlaying ? t('stop') : t('play')} + + + {selected ? t('selected') : t('select')} + +
+
- - - - {audio.isPlaying ? t('stop') : t('play')} - - - {selected ? t('selected') : t('select')} - - - - +
) } diff --git a/src/components/changeTheme/changePage.tsx b/src/components/changeTheme/changePage.tsx index 71043f6..e073f5a 100644 --- a/src/components/changeTheme/changePage.tsx +++ b/src/components/changeTheme/changePage.tsx @@ -3,7 +3,9 @@ import { Focusable, PanelSection, PanelSectionRow, + ServerAPI, SteamSpinner, + TextField, useParams } from 'decky-frontend-lib' import React, { useEffect, useState } from 'react' @@ -11,21 +13,21 @@ import { useSettings } from '../../context/settingsContext' import AudioPlayer from './audioPlayer' import { getCache, updateCache } from '../../cache/musicCache' import useTranslations from '../../hooks/useTranslations' +import YouTubeVideo from '../../../types/YouTube' +import NoMusic from './noMusic' export default function ChangePage({ - audios, + customSearch, + handlePlay, loading, - handlePlay + serverAPI, + videos }: { - audios: { - appName: string - title: string - videoId: string - audioUrl: string - isPlaying: boolean - }[] + videos: (YouTubeVideo & { isPlaying: boolean })[] loading: boolean handlePlay: (idx: number, startPlaying: boolean) => void + serverAPI: ServerAPI + customSearch: (term: string | undefined) => void }) { const t = useTranslations() const { state: settingsState } = useSettings() @@ -33,84 +35,94 @@ export default function ChangePage({ const appDetails = appStore.GetAppOverviewByGameID(parseInt(appid)) const appName = appDetails?.display_name const [selected, setSelected] = useState() + const [searchTerm, setSearchTerm] = useState('') useEffect(() => { async function getData() { const cache = await getCache(parseInt(appid)) - let newSelected - if (cache?.disabled) { - newSelected = '' - } else if (!cache?.videoId?.length) { - newSelected = undefined - } else { - newSelected = cache?.videoId - } - setSelected(newSelected) + setSelected(cache?.videoId) } getData() }, [appid]) function selectNewAudio(audio: { - appName: string title: string - videoId: string + videoId: string | undefined audioUrl: string - disabled: boolean }) { - let newSelected - if (audio?.disabled) { - newSelected = '' - } else if (!audio.videoId?.length) { - newSelected = undefined - } else { - newSelected = audio.videoId - } - setSelected(newSelected) - updateCache(parseInt(appid), audio) + setSelected(audio.videoId) + updateCache(parseInt(appid), { videoId: audio.videoId }) } return (
-

{appName}

+

{appName}

+ + + + setSearchTerm(e.target.value)} + value={searchTerm} + /> + customSearch(searchTerm)} + > + {t('search')} + + { + setSearchTerm('') + customSearch(undefined) + }} + > + {t('clear')} + + + + {loading ? ( ) : ( <> - - - - - {t('play')} - - - selectNewAudio({ - appName: appName, - title: '', - videoId: '', - audioUrl: '', - disabled: true - }) - } - > - {selected === '' ? t('selected') : t('select')} - - - - - {audios.map((audio, index) => ( - { - handlePlay(index, status) - }} - selected={selected === audio.videoId} + + - ))} + {videos.map((video, index) => ( + { + handlePlay(index, status) + }} + selected={selected === video.id} + selectNewAudio={selectNewAudio} + /> + ))} + )}
diff --git a/src/components/changeTheme/index.tsx b/src/components/changeTheme/index.tsx index 6be7f58..96b37ee 100644 --- a/src/components/changeTheme/index.tsx +++ b/src/components/changeTheme/index.tsx @@ -5,10 +5,8 @@ import useTranslations from '../../hooks/useTranslations' import ChangePage from './changePage' import AboutPage from './aboutPage' import { SettingsProvider } from '../../context/settingsContext' -import { - getAudioUrlFromVideoId, - getYouTubeSearchResults -} from '../../actions/audio' +import { getYouTubeSearchResults } from '../../actions/audio' +import YouTubeVideo from '../../../types/YouTube' export default function ChangeTheme({ serverAPI }: { serverAPI: ServerAPI }) { const [currentTab, setCurrentTab] = useState() @@ -17,50 +15,35 @@ export default function ChangeTheme({ serverAPI }: { serverAPI: ServerAPI }) { const appDetails = appStore.GetAppOverviewByGameID(parseInt(appid)) const appName = appDetails?.display_name - const [audios, setAudios] = useState< - { - appName: string - title: string - videoId: string - audioUrl: string - isPlaying: boolean - }[] + const [videos, setVideos] = useState< + (YouTubeVideo & { isPlaying: boolean })[] >([]) const [loading, setLoading] = useState(true) + const [searchTerm, setSearchTerm] = useState() useEffect(() => { async function getData() { setLoading(true) - const res = await getYouTubeSearchResults(serverAPI, appName) - if (res?.length) { - const audios = await Promise.all( - res.map(async (v) => await getAudioUrlFromVideoId(serverAPI, v)) - ) - const filteredAudios = audios - .filter((a) => a?.audioUrl?.length) - .map((a) => ({ ...a, isPlaying: false })) as { - appName: string - title: string - videoId: string - audioUrl: string - isPlaying: boolean - }[] - setAudios(filteredAudios || []) - setLoading(false) - } + const res = await getYouTubeSearchResults( + serverAPI, + searchTerm?.length ? searchTerm : appName, + Boolean(searchTerm?.length) + ) + setVideos(res?.map((v) => ({ ...v, isPlaying: false })) || []) + setLoading(false) } - if (appid) { + if (appName) { getData() } - }, []) + }, [searchTerm, appName]) function handlePlay(index: number, startPlay: boolean) { - setAudios((oldAudios) => { - const newAudios = oldAudios.map((a, aIndex) => ({ - ...a, - isPlaying: aIndex === index ? startPlay : false + setVideos((oldVideos) => { + const newVideos = oldVideos.map((v, vIndex) => ({ + ...v, + isPlaying: vIndex === index ? startPlay : false })) - return newAudios + return newVideos }) } @@ -82,9 +65,11 @@ export default function ChangeTheme({ serverAPI }: { serverAPI: ServerAPI }) { content: ( ), diff --git a/src/components/changeTheme/noMusic.tsx b/src/components/changeTheme/noMusic.tsx new file mode 100644 index 0000000..7f04137 --- /dev/null +++ b/src/components/changeTheme/noMusic.tsx @@ -0,0 +1,89 @@ +import { DialogButton, Focusable } from 'decky-frontend-lib' +import React from 'react' +import useTranslations from '../../hooks/useTranslations' + +import { FaVolumeMute } from 'react-icons/fa' + +export default function NoMusic({ + selected, + selectNewAudio +}: { + selected: boolean + selectNewAudio: (audio: { + title: string + videoId: string | undefined + audioUrl: string + }) => void +}) { + const t = useTranslations() + return ( +
+ +
+ +
+

+ {t('noMusicLabel')} +

+ +
+ + {t('play')} + + + selectNewAudio({ + title: '', + videoId: '', + audioUrl: '' + }) + } + > + {selected ? t('selected') : t('select')} + +
+
+
+ ) +} diff --git a/src/hooks/useThemeMusic.ts b/src/hooks/useThemeMusic.ts index cfd5ec8..9f1f096 100644 --- a/src/hooks/useThemeMusic.ts +++ b/src/hooks/useThemeMusic.ts @@ -6,12 +6,9 @@ import { getAudio, getAudioUrlFromVideoId } from '../actions/audio' import { getCache, updateCache } from '../cache/musicCache' const useThemeMusic = (serverAPI: ServerAPI, appId: number) => { - const [audio, setAudio] = useState<{ - appName: string - title: string - videoId: string - audioUrl: string - }>() + const [audio, setAudio] = useState< + { videoId: string; audioUrl: string } | undefined + >() const appDetails = appStore.GetAppOverviewByGameID(appId) const appName = appDetails?.display_name @@ -19,23 +16,26 @@ const useThemeMusic = (serverAPI: ServerAPI, appId: number) => { let ignore = false async function getData() { const cache = await getCache(appId) - if (cache?.disabled) { - return + if (cache?.videoId && !cache?.videoId?.length) { + return setAudio({ videoId: '', audioUrl: '' }) } if (cache?.videoId?.length) { const newAudio = await getAudioUrlFromVideoId(serverAPI, { - appName, - title: cache.title, + title: '', id: cache.videoId }) - return setAudio(newAudio) + if (newAudio?.length) { + return setAudio({ videoId: cache.videoId, audioUrl: newAudio }) + } } const newAudio = await getAudio(serverAPI, appName as string) if (ignore) { return } - if (!newAudio?.audioUrl?.length) return - setAudio({ ...newAudio, appName }) + if (!newAudio?.audioUrl?.length) { + return setAudio({ videoId: '', audioUrl: '' }) + } + setAudio(newAudio) } if (appName?.length) { getData() @@ -48,8 +48,6 @@ const useThemeMusic = (serverAPI: ServerAPI, appId: number) => { useEffect(() => { if (audio?.videoId) { updateCache(appId, { - appName, - title: audio.title, videoId: audio.videoId }) } diff --git a/src/localisation/en.json b/src/localisation/en.json index e04523d..76e185c 100644 --- a/src/localisation/en.json +++ b/src/localisation/en.json @@ -3,11 +3,13 @@ "aboutDescription": "Play theme songs on your game pages", "aboutLabel": "About Game Theme Music", "changeThemeMusic": "Change Theme Music", + "clear": "Clear", "deleteOverrides": "Delete Overrides", "deleteOverridesLabel": "Delete all overrides", "noMusicLabel": "No Music", "overrides": "Overrides", "play": "Play", + "search": "Search", "select": "Select", "selected": "Selected", "settings": "Settings", diff --git a/types/YouTube.ts b/types/YouTube.ts new file mode 100644 index 0000000..ac92f8a --- /dev/null +++ b/types/YouTube.ts @@ -0,0 +1,3 @@ +type YouTubeVideo = { title: string; id: string; thumbnail: string } + +export default YouTubeVideo