From 7ed847fecb47fbdbc70e3b34dc1728024e774e94 Mon Sep 17 00:00:00 2001 From: Jeff <42182408+jeffvli@users.noreply.github.com> Date: Sat, 13 Dec 2025 21:26:33 -0800 Subject: [PATCH] Add internet radio (#1384) --- package.json | 1 + pnpm-lock.yaml | 23 ++ src/i18n/locales/en.json | 17 + src/main/features/core/player/index.ts | 88 ++++ src/preload/mpv-player.ts | 15 + src/renderer/api/controller.ts | 55 +++ .../api/jellyfin/jellyfin-controller.ts | 71 ++++ .../api/navidrome/navidrome-controller.ts | 4 + src/renderer/api/query-keys.ts | 4 + src/renderer/api/subsonic/subsonic-api.ts | 31 ++ .../api/subsonic/subsonic-controller.ts | 65 ++- .../audio-player/engine/mpv-player-engine.tsx | 39 +- .../player/components/audio-players.tsx | 18 + .../player/components/center-controls.tsx | 91 ++++- .../player/components/left-controls.tsx | 196 ++++----- .../components/radio-metadata-display.tsx | 75 ++++ src/renderer/features/radio/api/radio-api.ts | 20 + .../components/create-radio-station-form.tsx | 113 ++++++ .../components/edit-radio-station-form.tsx | 126 ++++++ .../radio/components/radio-list-content.tsx | 64 +++ .../components/radio-list-header-filters.tsx | 47 +++ .../radio/components/radio-list-header.tsx | 40 ++ .../components/radio-list-items.module.css | 30 ++ .../radio/components/radio-list-items.tsx | 168 ++++++++ .../features/radio/hooks/use-radio-player.ts | 376 ++++++++++++++++++ .../create-radio-station-mutation.ts | 36 ++ .../delete-radio-station-mutation.ts | 36 ++ .../update-radio-station-mutation.ts | 36 ++ .../radio/routes/radio-list-route.tsx | 42 ++ .../features/radio/store/radio-store.ts | 115 ++++++ .../components/general/sidebar-reorder.tsx | 1 + .../components/list-sort-by-dropdown.tsx | 43 ++ src/renderer/features/shared/utils.ts | 12 +- .../sidebar/components/collapsed-sidebar.tsx | 1 + .../sidebar/components/sidebar-icon.tsx | 5 + .../features/sidebar/components/sidebar.tsx | 6 +- src/renderer/layouts/window-bar.tsx | 40 +- src/renderer/router/app-router.tsx | 3 + src/renderer/router/routes.ts | 1 + src/renderer/store/auth.store.ts | 5 + src/renderer/store/settings.store.ts | 17 +- src/shared/api/subsonic/subsonic-normalize.ts | 13 + src/shared/api/subsonic/subsonic-types.ts | 46 +++ src/shared/api/utils.ts | 29 ++ src/shared/types/domain-types.ts | 82 +++- src/shared/types/types.ts | 1 + 46 files changed, 2229 insertions(+), 118 deletions(-) create mode 100644 src/renderer/features/player/components/radio-metadata-display.tsx create mode 100644 src/renderer/features/radio/api/radio-api.ts create mode 100644 src/renderer/features/radio/components/create-radio-station-form.tsx create mode 100644 src/renderer/features/radio/components/edit-radio-station-form.tsx create mode 100644 src/renderer/features/radio/components/radio-list-content.tsx create mode 100644 src/renderer/features/radio/components/radio-list-header-filters.tsx create mode 100644 src/renderer/features/radio/components/radio-list-header.tsx create mode 100644 src/renderer/features/radio/components/radio-list-items.module.css create mode 100644 src/renderer/features/radio/components/radio-list-items.tsx create mode 100644 src/renderer/features/radio/hooks/use-radio-player.ts create mode 100644 src/renderer/features/radio/mutations/create-radio-station-mutation.ts create mode 100644 src/renderer/features/radio/mutations/delete-radio-station-mutation.ts create mode 100644 src/renderer/features/radio/mutations/update-radio-station-mutation.ts create mode 100644 src/renderer/features/radio/routes/radio-list-route.tsx create mode 100644 src/renderer/features/radio/store/radio-store.ts diff --git a/package.json b/package.json index d3fd7c4a..9b6e6a85 100644 --- a/package.json +++ b/package.json @@ -97,6 +97,7 @@ "format-duration": "^3.0.2", "fuse.js": "^7.1.0", "i18next": "^25.6.2", + "icecast-metadata-stats": "^0.1.12", "idb-keyval": "^6.2.2", "immer": "^10.2.0", "is-electron": "^2.2.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4d07cac1..0548c0ef 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: i18next: specifier: ^25.6.2 version: 25.6.2(typescript@5.8.3) + icecast-metadata-stats: + specifier: ^0.1.12 + version: 0.1.12 idb-keyval: specifier: ^6.2.2 version: 6.2.2 @@ -2473,6 +2476,9 @@ packages: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codec-parser@2.5.0: + resolution: {integrity: sha512-Ru9t80fV8B0ZiixQl8xhMTLru+dzuis/KQld32/x5T/+3LwZb0/YvQdSKytX9JqCnRdiupvAvyYJINKrXieziQ==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -3443,6 +3449,13 @@ packages: typescript: optional: true + icecast-metadata-js@1.2.9: + resolution: {integrity: sha512-8YqPrJ4AjM64O28xF9TSUUFczxnTKwXwnIPmZKRxdbaZb6hn0nP+ke1OGNA+UsIfLpNRW4acDDBkIkbynYVQig==} + + icecast-metadata-stats@0.1.12: + resolution: {integrity: sha512-qywYIIvxjAmZIFNUXMVZ/IgIJh87z0W6oOmJ5htPw3SUauXcYoY6rRexvzN5Ibct8hXsqoTcB+k8m6Wa53bfJg==} + engines: {node: '>=18.0.0'} + iconv-corefoundation@1.1.7: resolution: {integrity: sha512-T10qvkw0zz4wnm560lOEg0PovVqUXuOFhhHAkixw8/sycy7TJt7v/RrkEKEQnAw2viPSJu6iAkErxnzR0g8PpQ==} engines: {node: ^8.11.2 || >=10} @@ -8274,6 +8287,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codec-parser@2.5.0: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -9521,6 +9536,14 @@ snapshots: optionalDependencies: typescript: 5.8.3 + icecast-metadata-js@1.2.9: + dependencies: + codec-parser: 2.5.0 + + icecast-metadata-stats@0.1.12: + dependencies: + icecast-metadata-js: 1.2.9 + iconv-corefoundation@1.1.7: dependencies: cli-truncate: 2.1.0 diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index dd578756..837ffecb 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -4,7 +4,9 @@ "addToPlaylist": "add to $t(entity.playlist_one)", "clearQueue": "clear queue", "createPlaylist": "create $t(entity.playlist_one)", + "createRadioStation": "create $t(entity.radioStation_one)", "deletePlaylist": "delete $t(entity.playlist_one)", + "deleteRadioStation": "delete $t(entity.radioStation_one)", "deselectAll": "deselect all", "downloadStarted": "started download of {{count}} items", "editPlaylist": "edit $t(entity.playlist_one)", @@ -158,6 +160,10 @@ "albumArtistCount_other": "{{count}} album artists", "albumWithCount_one": "{{count}} album", "albumWithCount_other": "{{count}} albums", + "radioStation_one": "radio station", + "radioStation_other": "radio stations", + "radioStationWithCount_one": "{{count}} radio station", + "radioStationWithCount_other": "{{count}} radio stations", "artist_one": "artist", "artist_other": "artists", "artistWithCount_one": "{{count}} artist", @@ -316,6 +322,13 @@ "success": "$t(entity.playlist_one) created successfully", "title": "create $t(entity.playlist_one)" }, + "createRadioStation": { + "success": "radio station created successfully", + "title": "create radio station", + "input_homepageUrl": "homepage url", + "input_name": "name", + "input_streamUrl": "stream url" + }, "deletePlaylist": { "input_confirm": "type the name of the $t(entity.playlist_one) to confirm", "success": "$t(entity.playlist_one) deleted successfully", @@ -398,6 +411,9 @@ "genreAlbums": "\"{{genre}}\" $t(entity.album_other)", "title": "$t(entity.album_other)" }, + "radioList": { + "title": "radio stations" + }, "favorites": { "title": "$t(entity.favorite_other)" }, @@ -546,6 +562,7 @@ "folders": "$t(entity.folder_other)", "genres": "$t(entity.genre_other)", "home": "$t(common.home)", + "radio": "$t(entity.radioStation_other)", "myLibrary": "my library", "nowPlaying": "now playing", "playlists": "$t(entity.playlist_other)", diff --git a/src/main/features/core/player/index.ts b/src/main/features/core/player/index.ts index dec78262..b4eabf1a 100644 --- a/src/main/features/core/player/index.ts +++ b/src/main/features/core/player/index.ts @@ -10,6 +10,8 @@ import { getMainWindow, sendToastToRenderer } from '../../../index'; import { createLog, isWindows } from '../../../utils'; import { store } from '../settings'; +import { PlayerData } from '/@/shared/types/domain-types'; + declare module 'node-mpv'; // function wait(timeout: number) { @@ -21,6 +23,7 @@ declare module 'node-mpv'; // } let mpvInstance: MpvAPI | null = null; +let currentPlayerData: null | PlayerData = null; const socketPath = isWindows() ? `\\\\.\\pipe\\mpvserver-${pid}` : `/tmp/node-mpv-${pid}.sock`; const NodeMpvErrorCode = { @@ -437,6 +440,91 @@ ipcMain.handle('player-get-time', async (): Promise => { } }); +// Updates the current player metadata (song data) +ipcMain.on('player-update-metadata', (_event, data: PlayerData) => { + currentPlayerData = data; +}); + +// Returns the current player metadata (song data) +ipcMain.handle('player-metadata', async (): Promise => { + return currentPlayerData; +}); + +// Returns the stream metadata from mpv (for radio streams) +ipcMain.handle( + 'player-stream-metadata', + async (): Promise => { + try { + const metadata = await getMpvInstance()?.getProperty('metadata'); + if (metadata && typeof metadata === 'object') { + // Try to get separate title and artist fields first + let artist: null | string = + (metadata['artist'] as string) || + (metadata['ARTIST'] as string) || + (metadata['icy-artist'] as string) || + null; + let title: null | string = + (metadata['title'] as string) || (metadata['TITLE'] as string) || null; + + // If we don't have separate fields, try to parse from combined formats + if (!title && !artist) { + const combinedTitle = + (metadata['icy-title'] as string) || + (metadata['StreamTitle'] as string) || + (metadata['stream-title'] as string) || + null; + + if (combinedTitle && typeof combinedTitle === 'string') { + // Try to parse "Artist - Title" format + const match = combinedTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/); + if (match) { + artist = match[1].trim() || null; + title = match[2].trim() || null; + } else { + // If no separator found, treat the whole thing as title + title = combinedTitle; + } + } + } else if (!title) { + // If we have artist but no title, try to get from combined format + const combinedTitle = + (metadata['icy-title'] as string) || + (metadata['StreamTitle'] as string) || + (metadata['stream-title'] as string) || + null; + if (combinedTitle && typeof combinedTitle === 'string') { + title = combinedTitle; + } + } else if (!artist) { + // If we have title but no artist, try to get from combined format + const combinedTitle = + (metadata['icy-title'] as string) || + (metadata['StreamTitle'] as string) || + (metadata['stream-title'] as string) || + null; + if ( + combinedTitle && + typeof combinedTitle === 'string' && + combinedTitle !== title + ) { + // Try to parse artist from combined format + const match = combinedTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/); + if (match && match[2].trim() === title) { + artist = match[1].trim() || null; + } + } + } + + return { artist, title }; + } + return null; + } catch (err: any | NodeMpvError) { + mpvLog({ action: `Failed to get stream metadata` }, err); + return null; + } + }, +); + enum MpvState { STARTED, IN_PROGRESS, diff --git a/src/preload/mpv-player.ts b/src/preload/mpv-player.ts index b393fca5..38e2bc6a 100644 --- a/src/preload/mpv-player.ts +++ b/src/preload/mpv-player.ts @@ -86,6 +86,18 @@ const getCurrentTime = async () => { return ipcRenderer.invoke('player-get-time'); }; +const updateMetadata = (data: PlayerData) => { + ipcRenderer.send('player-update-metadata', data); +}; + +const getMetadata = async () => { + return ipcRenderer.invoke('player-metadata'); +}; + +const getStreamMetadata = async () => { + return ipcRenderer.invoke('player-stream-metadata'); +}; + const rendererAutoNext = (cb: (event: IpcRendererEvent, data: PlayerData) => void) => { ipcRenderer.on('renderer-player-auto-next', cb); }; @@ -163,6 +175,8 @@ export const mpvPlayer = { cleanup, currentTime, getCurrentTime, + getMetadata, + getStreamMetadata, initialize, isRunning, mute, @@ -178,6 +192,7 @@ export const mpvPlayer = { setQueue, setQueueNext, stop, + updateMetadata, volume, }; diff --git a/src/renderer/api/controller.ts b/src/renderer/api/controller.ts index 189a0a18..4573caad 100644 --- a/src/renderer/api/controller.ts +++ b/src/renderer/api/controller.ts @@ -100,6 +100,20 @@ export const controller: GeneralController = { server.type, )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); }, + createInternetRadioStation(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: createInternetRadioStation`, + ); + } + + return apiController( + 'createInternetRadioStation', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, createPlaylist(args) { const server = getServerById(args.apiClientProps.serverId); @@ -128,6 +142,20 @@ export const controller: GeneralController = { server.type, )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); }, + deleteInternetRadioStation(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: deleteInternetRadioStation`, + ); + } + + return apiController( + 'deleteInternetRadioStation', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, deletePlaylist(args) { const server = getServerById(args.apiClientProps.serverId); @@ -342,6 +370,19 @@ export const controller: GeneralController = { query: mergeMusicFolderId(args.query, server), }); }, + getInternetRadioStations(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: getInternetRadioStations`, + ); + } + return apiController( + 'getInternetRadioStations', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, getLyrics(args) { const server = getServerById(args.apiClientProps.serverId); @@ -744,6 +785,20 @@ export const controller: GeneralController = { server.type, )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); }, + updateInternetRadioStation(args) { + const server = getServerById(args.apiClientProps.serverId); + + if (!server) { + throw new Error( + `${i18n.t('error.apiRouteError', { postProcess: 'sentenceCase' })}: updateInternetRadioStation`, + ); + } + + return apiController( + 'updateInternetRadioStation', + server.type, + )?.({ ...args, apiClientProps: { ...args.apiClientProps, server } }); + }, updatePlaylist(args) { const server = getServerById(args.apiClientProps.serverId); diff --git a/src/renderer/api/jellyfin/jellyfin-controller.ts b/src/renderer/api/jellyfin/jellyfin-controller.ts index 84c4de44..4bdf93c2 100644 --- a/src/renderer/api/jellyfin/jellyfin-controller.ts +++ b/src/renderer/api/jellyfin/jellyfin-controller.ts @@ -5,6 +5,7 @@ import orderBy from 'lodash/orderBy'; import { z } from 'zod'; import { jfApiClient } from '/@/renderer/api/jellyfin/jellyfin-api'; +import { useRadioStore } from '/@/renderer/features/radio/store/radio-store'; import { jfNormalize } from '/@/shared/api/jellyfin/jellyfin-normalize'; import { JFSongListSort, JFSortOrder, jfType } from '/@/shared/api/jellyfin/jellyfin-types'; import { getFeatures, hasFeature, sortSongList, VersionInfo } from '/@/shared/api/utils'; @@ -115,6 +116,26 @@ export const JellyfinController: InternalControllerEndpoint = { return null; }, + createInternetRadioStation: async (args) => { + const { apiClientProps, body } = args; + + if (!apiClientProps.serverId) { + throw new Error('No serverId found'); + } + + const state = useRadioStore.getState(); + if (!state?.actions?.createStation) { + throw new Error('Radio store not initialized'); + } + + state.actions.createStation(apiClientProps.serverId, { + homepageUrl: body.homepageUrl || null, + name: body.name, + streamUrl: body.streamUrl, + }); + + return null; + }, createPlaylist: async (args) => { const { apiClientProps, body } = args; @@ -158,6 +179,22 @@ export const JellyfinController: InternalControllerEndpoint = { return null; }, + deleteInternetRadioStation: async (args) => { + const { apiClientProps, query } = args; + + if (!apiClientProps.serverId) { + throw new Error('No serverId found'); + } + + const state = useRadioStore.getState(); + if (!state?.actions?.deleteStation) { + throw new Error('Radio store not initialized'); + } + + state.actions.deleteStation(apiClientProps.serverId, query.id); + + return null; + }, deletePlaylist: async (args) => { const { apiClientProps, query } = args; @@ -633,6 +670,20 @@ export const JellyfinController: InternalControllerEndpoint = { totalRecordCount: res.body?.TotalRecordCount || 0, }; }, + getInternetRadioStations: async (args) => { + const { apiClientProps } = args; + + if (!apiClientProps.serverId) { + throw new Error('No serverId found'); + } + + const state = useRadioStore.getState(); + if (!state?.actions?.getStations) { + throw new Error('Radio store not initialized'); + } + + return state.actions.getStations(apiClientProps.serverId); + }, getLyrics: async (args) => { const { apiClientProps, query } = args; @@ -1455,6 +1506,26 @@ export const JellyfinController: InternalControllerEndpoint = { songs: songs.map((item) => jfNormalize.song(item, apiClientProps.server)), }; }, + updateInternetRadioStation: async (args) => { + const { apiClientProps, body, query } = args; + + if (!apiClientProps.serverId) { + throw new Error('No serverId found'); + } + + const state = useRadioStore.getState(); + if (!state?.actions?.updateStation) { + throw new Error('Radio store not initialized'); + } + + state.actions.updateStation(apiClientProps.serverId, query.id, { + homepageUrl: body.homepageUrl || null, + name: body.name, + streamUrl: body.streamUrl, + }); + + return null; + }, updatePlaylist: async (args) => { const { apiClientProps, body, query } = args; diff --git a/src/renderer/api/navidrome/navidrome-controller.ts b/src/renderer/api/navidrome/navidrome-controller.ts index 80ba3bc7..20728069 100644 --- a/src/renderer/api/navidrome/navidrome-controller.ts +++ b/src/renderer/api/navidrome/navidrome-controller.ts @@ -141,6 +141,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, createFavorite: SubsonicController.createFavorite, + createInternetRadioStation: SubsonicController.createInternetRadioStation, createPlaylist: async (args) => { const { apiClientProps, body } = args; @@ -164,6 +165,7 @@ export const NavidromeController: InternalControllerEndpoint = { }; }, deleteFavorite: SubsonicController.deleteFavorite, + deleteInternetRadioStation: SubsonicController.deleteInternetRadioStation, deletePlaylist: async (args) => { const { apiClientProps, query } = args; @@ -459,6 +461,7 @@ export const NavidromeController: InternalControllerEndpoint = { totalRecordCount: Number(res.body.headers.get('x-total-count') || 0), }; }, + getInternetRadioStations: SubsonicController.getInternetRadioStations, getLyrics: SubsonicController.getLyrics, getMusicFolderList: SubsonicController.getMusicFolderList, getPlaylistDetail: async (args) => { @@ -931,6 +934,7 @@ export const NavidromeController: InternalControllerEndpoint = { id: res.body.data.id, }; }, + updateInternetRadioStation: SubsonicController.updateInternetRadioStation, updatePlaylist: async (args) => { const { apiClientProps, body, query } = args; diff --git a/src/renderer/api/query-keys.ts b/src/renderer/api/query-keys.ts index f6300673..9f53bd97 100644 --- a/src/renderer/api/query-keys.ts +++ b/src/renderer/api/query-keys.ts @@ -322,6 +322,10 @@ export const queryKeys: Record< return [serverId, 'playlists', 'songList'] as const; }, }, + radio: { + list: (serverId: string) => [serverId, 'radio', 'list'] as const, + root: (serverId: string) => [serverId, 'radio'] as const, + }, roles: { list: (serverId: string) => [serverId, 'roles'] as const, }, diff --git a/src/renderer/api/subsonic/subsonic-api.ts b/src/renderer/api/subsonic/subsonic-api.ts index 3836f8ca..7b672af7 100644 --- a/src/renderer/api/subsonic/subsonic-api.ts +++ b/src/renderer/api/subsonic/subsonic-api.ts @@ -30,6 +30,14 @@ export const contract = c.router({ 200: ssType._response.createFavorite, }, }, + createInternetRadioStation: { + method: 'GET', + path: 'createInternetRadioStation.view', + query: ssType._parameters.createInternetRadioStation, + responses: { + 200: ssType._response.createInternetRadioStation, + }, + }, createPlaylist: { method: 'GET', path: 'createPlaylist.view', @@ -38,6 +46,14 @@ export const contract = c.router({ 200: ssType._response.createPlaylist, }, }, + deleteInternetRadioStation: { + method: 'GET', + path: 'deleteInternetRadioStation.view', + query: ssType._parameters.deleteInternetRadioStation, + responses: { + 200: ssType._response.deleteInternetRadioStation, + }, + }, deletePlaylist: { method: 'GET', path: 'deletePlaylist.view', @@ -110,6 +126,13 @@ export const contract = c.router({ 200: ssType._response.getIndexes, }, }, + getInternetRadioStations: { + method: 'GET', + path: 'getInternetRadioStations.view', + responses: { + 200: ssType._response.getInternetRadioStations, + }, + }, getMusicDirectory: { method: 'GET', path: 'getMusicDirectory.view', @@ -281,6 +304,14 @@ export const contract = c.router({ 200: ssType._response.setRating, }, }, + updateInternetRadioStation: { + method: 'GET', + path: 'updateInternetRadioStation.view', + query: ssType._parameters.updateInternetRadioStation, + responses: { + 200: ssType._response.updateInternetRadioStation, + }, + }, updatePlaylist: { method: 'GET', path: 'updatePlaylist.view', diff --git a/src/renderer/api/subsonic/subsonic-controller.ts b/src/renderer/api/subsonic/subsonic-controller.ts index 2e91e1e7..b47af74f 100644 --- a/src/renderer/api/subsonic/subsonic-controller.ts +++ b/src/renderer/api/subsonic/subsonic-controller.ts @@ -166,6 +166,23 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, + createInternetRadioStation: async (args) => { + const { apiClientProps, body } = args; + + const res = await ssApiClient(apiClientProps).createInternetRadioStation({ + query: { + homepageUrl: body.homepageUrl, + name: body.name, + streamUrl: body.streamUrl, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to create internet radio station'); + } + + return null; + }, createPlaylist: async ({ apiClientProps, body }) => { const res = await ssApiClient(apiClientProps).createPlaylist({ query: { @@ -199,6 +216,21 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, + deleteInternetRadioStation: async (args) => { + const { apiClientProps, query } = args; + + const res = await ssApiClient(apiClientProps).deleteInternetRadioStation({ + query: { + id: query.id, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to delete internet radio station'); + } + + return null; + }, deletePlaylist: async (args) => { const { apiClientProps, query } = args; @@ -789,6 +821,19 @@ export const SubsonicController: InternalControllerEndpoint = { startIndex: query.startIndex, }); }, + getInternetRadioStations: async (args) => { + const { apiClientProps } = args; + + const res = await ssApiClient(apiClientProps).getInternetRadioStations(); + + if (res.status !== 200) { + throw new Error('Failed to get internet radio stations'); + } + + const stations = res.body.internetRadioStations?.internetRadioStation || []; + + return stations.map((station) => ssNormalize.internetRadioStation(station)); + }, getMusicFolderList: async (args) => { const { apiClientProps } = args; @@ -822,6 +867,7 @@ export const SubsonicController: InternalControllerEndpoint = { return ssNormalize.playlist(res.body.playlist, apiClientProps.server); }, + getPlaylistList: async ({ apiClientProps, query }) => { const sortOrder = query.sortOrder.toLowerCase() as 'asc' | 'desc'; @@ -1005,7 +1051,6 @@ export const SubsonicController: InternalControllerEndpoint = { final.splice(0, 0, { label: 'all artists', value: '' }); return final; }, - getServerInfo: async (args) => { const { apiClientProps } = args; @@ -1722,6 +1767,24 @@ export const SubsonicController: InternalControllerEndpoint = { return null; }, + updateInternetRadioStation: async (args) => { + const { apiClientProps, body, query } = args; + + const res = await ssApiClient(apiClientProps).updateInternetRadioStation({ + query: { + homepageUrl: body.homepageUrl, + id: query.id, + name: body.name, + streamUrl: body.streamUrl, + }, + }); + + if (res.status !== 200) { + throw new Error('Failed to update internet radio station'); + } + + return null; + }, updatePlaylist: async (args) => { const { apiClientProps, body, query } = args; diff --git a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx index eef77c1f..327acacd 100644 --- a/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx +++ b/src/renderer/features/player/audio-player/engine/mpv-player-engine.tsx @@ -6,6 +6,7 @@ import { useEffect, useImperativeHandle, useRef, useState } from 'react'; import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; import { getSongUrl } from '/@/renderer/features/player/audio-player/hooks/use-stream-url'; import { AudioPlayer, PlayerOnProgressProps } from '/@/renderer/features/player/audio-player/types'; +import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player'; import { getMpvProperties } from '/@/renderer/features/settings/components/playback/mpv-settings'; import { usePlaybackSettings, @@ -100,17 +101,22 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { isInitializedRef.current = true; // After initialization, populate the queue if currentSrc is available - const playerData = usePlayerStore.getState().getPlayerData(); - const currentSongUrl = playerData.currentSong - ? getSongUrl(playerData.currentSong, transcode) - : undefined; - const nextSongUrl = playerData.nextSong - ? getSongUrl(playerData.nextSong, transcode) - : undefined; + // Don't override queue if radio is active + const radioState = useRadioStore.getState(); - if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { - mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true); - hasPopulatedQueueRef.current = true; + if (!radioState.currentStreamUrl) { + const playerData = usePlayerStore.getState().getPlayerData(); + const currentSongUrl = playerData.currentSong + ? getSongUrl(playerData.currentSong, transcode) + : undefined; + const nextSongUrl = playerData.nextSong + ? getSongUrl(playerData.nextSong, transcode) + : undefined; + + if (currentSongUrl && nextSongUrl && !hasPopulatedQueueRef.current && mpvPlayer) { + mpvPlayer.setQueue(currentSongUrl, nextSongUrl, true); + hasPopulatedQueueRef.current = true; + } } }; @@ -243,6 +249,12 @@ export const MpvPlayerEngine = (props: MpvPlayerEngineProps) => { replaceMpvQueue(transcode); }, onNextSongInsertion: (song) => { + const radioState = useRadioStore.getState(); + + if (radioState.currentStreamUrl) { + return; + } + const nextSongUrl = song ? getSongUrl(song, transcode) : undefined; mpvPlayer?.setQueueNext(nextSongUrl); }, @@ -317,6 +329,13 @@ function replaceMpvQueue(transcode: { enabled: boolean; format?: string | undefined; }) { + // Don't override queue if radio is active + const radioState = useRadioStore.getState(); + + if (radioState.currentStreamUrl) { + return; + } + const playerData = usePlayerStore.getState().getPlayerData(); const currentSongUrl = playerData.currentSong ? getSongUrl(playerData.currentSong, transcode) diff --git a/src/renderer/features/player/components/audio-players.tsx b/src/renderer/features/player/components/audio-players.tsx index 5dbe83e4..3eb58f04 100644 --- a/src/renderer/features/player/components/audio-players.tsx +++ b/src/renderer/features/player/components/audio-players.tsx @@ -15,6 +15,11 @@ import { usePowerSaveBlocker } from '/@/renderer/features/player/hooks/use-power import { useQueueRestoreTimestamp } from '/@/renderer/features/player/hooks/use-queue-restore'; import { useScrobble } from '/@/renderer/features/player/hooks/use-scrobble'; import { useWebAudio } from '/@/renderer/features/player/hooks/use-webaudio'; +import { + useIsRadioActive, + useRadioAudioInstance, + useRadioMetadata, +} from '/@/renderer/features/radio/hooks/use-radio-player'; import { updateQueueFavorites, updateQueueRatings, @@ -49,6 +54,9 @@ export const AudioPlayers = () => { useAutoDJ(); useQueueRestoreTimestamp(); + useRadioAudioInstance(); + useRadioMetadata(); + useEffect(() => { if (webAudio && 'AudioContext' in window) { let context: AudioContext; @@ -124,6 +132,16 @@ export const AudioPlayers = () => { }; }, [serverId]); + const isRadioActive = useIsRadioActive(); + + if (isRadioActive && playbackType === PlayerType.LOCAL) { + return ; + } + + if (isRadioActive && playbackType === PlayerType.WEB) { + return null; + } + return ( <> {playbackType === PlayerType.WEB && } diff --git a/src/renderer/features/player/components/center-controls.tsx b/src/renderer/features/player/components/center-controls.tsx index 34eddc9c..05c445a4 100644 --- a/src/renderer/features/player/components/center-controls.tsx +++ b/src/renderer/features/player/components/center-controls.tsx @@ -6,6 +6,12 @@ import { MainPlayButton, PlayerButton } from '/@/renderer/features/player/compon import { PlayerbarSlider } from '/@/renderer/features/player/components/playerbar-slider'; import { openShuffleAllModal } from '/@/renderer/features/player/components/shuffle-all-modal'; import { usePlayer } from '/@/renderer/features/player/context/player-context'; +import { + useIsPlayingRadio, + useIsRadioActive, + useRadioControls, + useRadioPlayer, +} from '/@/renderer/features/radio/hooks/use-radio-player'; import { usePlayerRepeat, usePlayerShuffle, @@ -19,6 +25,28 @@ import { PlayerRepeat, PlayerShuffle, PlayerStatus } from '/@/shared/types/types export const CenterControls = () => { const skip = useSettingsStore((state) => state.general.skipButtons); + const isRadioActive = useIsRadioActive(); + + if (isRadioActive) { + return ( + <> +
+
+ + + + {skip?.enabled && } + + {skip?.enabled && } + + + +
+
+ + ); + } + return ( <>
@@ -39,13 +67,49 @@ export const CenterControls = () => { ); }; -const StopButton = () => { +const RadioCenterPlayButton = ({ disabled }: { disabled?: boolean }) => { + const { currentStreamUrl } = useRadioPlayer(); + const isPlayingRadio = useIsPlayingRadio(); + const { pause, play } = useRadioControls(); + + const handleClick = () => { + if (isPlayingRadio) { + pause(); + } else if (currentStreamUrl) { + play(); + } + }; + + return ; +}; + +const RadioStopButton = ({ disabled }: { disabled?: boolean }) => { + const { t } = useTranslation(); + const buttonSize = useSettingsStore((state) => state.general.buttonSize); + const { stop } = useRadioControls(); + + return ( + } + onClick={stop} + tooltip={{ + label: t('player.stop', { postProcess: 'sentenceCase' }), + openDelay: 0, + }} + variant="tertiary" + /> + ); +}; + +const StopButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const { mediaStop } = usePlayer(); return ( } onClick={mediaStop} tooltip={{ @@ -57,7 +121,7 @@ const StopButton = () => { ); }; -const ShuffleButton = () => { +const ShuffleButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const shuffle = usePlayerShuffle(); @@ -65,6 +129,7 @@ const ShuffleButton = () => { return ( { ); }; -const PreviousButton = () => { +const PreviousButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const { mediaPrevious } = usePlayer(); return ( } onClick={mediaPrevious} tooltip={{ @@ -107,13 +173,14 @@ const PreviousButton = () => { ); }; -const SkipBackwardButton = () => { +const SkipBackwardButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const { mediaSkipBackward } = usePlayer(); return ( } onClick={mediaSkipBackward} tooltip={{ @@ -128,27 +195,28 @@ const SkipBackwardButton = () => { ); }; -const CenterPlayButton = () => { +const CenterPlayButton = ({ disabled }: { disabled?: boolean }) => { const currentSong = usePlayerSong(); const status = usePlayerStatus(); const { mediaTogglePlayPause } = usePlayer(); return ( ); }; -const SkipForwardButton = () => { +const SkipForwardButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const { mediaSkipForward } = usePlayer(); return ( } onClick={mediaSkipForward} tooltip={{ @@ -163,13 +231,14 @@ const SkipForwardButton = () => { ); }; -const NextButton = () => { +const NextButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const { mediaNext } = usePlayer(); return ( } onClick={mediaNext} tooltip={{ @@ -181,7 +250,7 @@ const NextButton = () => { ); }; -const RepeatButton = () => { +const RepeatButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); const repeat = usePlayerRepeat(); @@ -189,6 +258,7 @@ const RepeatButton = () => { return ( @@ -226,12 +296,13 @@ const RepeatButton = () => { ); }; -const ShuffleAllButton = () => { +const ShuffleAllButton = ({ disabled }: { disabled?: boolean }) => { const { t } = useTranslation(); const buttonSize = useSettingsStore((state) => state.general.buttonSize); return ( } onClick={() => openShuffleAllModal()} tooltip={{ diff --git a/src/renderer/features/player/components/left-controls.tsx b/src/renderer/features/player/components/left-controls.tsx index 311a7efb..1fbf8cb4 100644 --- a/src/renderer/features/player/components/left-controls.tsx +++ b/src/renderer/features/player/components/left-controls.tsx @@ -8,6 +8,8 @@ import { shallow } from 'zustand/shallow'; import styles from './left-controls.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; +import { RadioMetadataDisplay } from '/@/renderer/features/player/components/radio-metadata-display'; +import { useIsRadioActive } from '/@/renderer/features/radio/hooks/use-radio-player'; import { AppRoute } from '/@/renderer/router/routes'; import { useAppStore, @@ -41,13 +43,15 @@ export const LeftControls = () => { shallow, ); - const hideImage = image && !collapsed; const currentSong = usePlayerSong(); - const title = currentSong?.name; - const artists = currentSong?.artists; + const isRadioActive = useIsRadioActive(); const { bindings } = useHotkeySettings(); - const isSongDefined = Boolean(currentSong?.id); + const isRadioMode = isRadioActive; + const hideImage = (image && !collapsed) || isRadioMode; + const isSongDefined = Boolean(currentSong?.id) && !isRadioMode; + const title = currentSong?.name; + const artists = currentSong?.artists; const handleToggleFullScreenPlayer = (e?: KeyboardEvent | MouseEvent) => { // don't toggle if right click @@ -118,7 +122,7 @@ export const LeftControls = () => { PlaybackSelectors.playerCoverArt, )} loading="eager" - src={currentSong?.imageUrl ?? ''} + src={isRadioMode ? '' : (currentSong?.imageUrl ?? '')} /> {!collapsed && ( @@ -148,101 +152,113 @@ export const LeftControls = () => { )} -
- - - {title || '—'} - - {isSongDefined && ( - { - e.preventDefault(); - e.stopPropagation(); - if (currentSong) { - ContextMenuController.call({ - cmd: { - items: [currentSong], - type: LibraryItem.SONG, + {isRadioMode ? ( + + ) : ( + <> +
+ + + {title || '—'} + + {isSongDefined && ( + { + e.preventDefault(); + e.stopPropagation(); + if (currentSong) { + ContextMenuController.call({ + cmd: { + items: [currentSong], + type: LibraryItem.SONG, + }, + event: e, + }); + } + }} + size="xs" + styles={{ + root: { + '--ai-size-xs': '1.15rem', }, - event: e, - }); - } - }} - size="xs" - styles={{ - root: { - '--ai-size-xs': '1.15rem', - }, - }} - variant="subtle" - /> - )} - -
-
- {artists?.map((artist, index) => ( - - {index > 0 && } + }} + variant="subtle" + /> + )} + +
+
+ {artists?.map((artist, index) => ( + + {index > 0 && } + + {artist.name || '—'} + + + ))} +
+
- {artist.name || '—'} + {currentSong?.album || '—'} - - ))} -
-
- - {currentSong?.album || '—'} - -
+
+ + )}
diff --git a/src/renderer/features/player/components/radio-metadata-display.tsx b/src/renderer/features/player/components/radio-metadata-display.tsx new file mode 100644 index 00000000..adbe165d --- /dev/null +++ b/src/renderer/features/player/components/radio-metadata-display.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx'; +import React from 'react'; +import { Link } from 'react-router'; + +import styles from './left-controls.module.css'; + +import { useIsRadioActive, useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player'; +import { AppRoute } from '/@/renderer/router/routes'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { Text } from '/@/shared/components/text/text'; +import { PlaybackSelectors } from '/@/shared/constants/playback-selectors'; + +interface RadioMetadataDisplayProps { + onStopPropagation: (e?: React.MouseEvent) => void; + onToggleContextMenu: (e: React.MouseEvent) => void; +} + +export const RadioMetadataDisplay = ({ + onStopPropagation, + onToggleContextMenu, +}: RadioMetadataDisplayProps) => { + const radioMetadata = useRadioStore((state) => state.metadata); + const stationName = useRadioStore((state) => state.stationName); + + const isRadioActive = useIsRadioActive(); + + if (!isRadioActive) { + return null; + } + + return ( + <> +
+ + {radioMetadata?.title || '—'} + +
+
+ + {radioMetadata?.artist || '—'} + +
+
+ + + + {stationName || '—'} + + +
+ + ); +}; diff --git a/src/renderer/features/radio/api/radio-api.ts b/src/renderer/features/radio/api/radio-api.ts new file mode 100644 index 00000000..7eff1eec --- /dev/null +++ b/src/renderer/features/radio/api/radio-api.ts @@ -0,0 +1,20 @@ +import { queryOptions } from '@tanstack/react-query'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { QueryHookArgs } from '/@/renderer/lib/react-query'; + +export const radioQueries = { + list: (args: QueryHookArgs) => { + return queryOptions({ + gcTime: 1000 * 60 * 60, + queryFn: ({ signal }) => { + return api.controller.getInternetRadioStations({ + apiClientProps: { serverId: args.serverId, signal }, + }); + }, + queryKey: queryKeys.radio.list(args.serverId || ''), + ...args.options, + }); + }, +}; diff --git a/src/renderer/features/radio/components/create-radio-station-form.tsx b/src/renderer/features/radio/components/create-radio-station-form.tsx new file mode 100644 index 00000000..d1e833e0 --- /dev/null +++ b/src/renderer/features/radio/components/create-radio-station-form.tsx @@ -0,0 +1,113 @@ +import { t } from 'i18next'; +import { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useCreateRadioStation } from '/@/renderer/features/radio/mutations/create-radio-station-mutation'; +import { useCurrentServer } from '/@/renderer/store'; +import { Group } from '/@/shared/components/group/group'; +import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; +import { useForm } from '/@/shared/hooks/use-form'; +import { CreateInternetRadioStationBody, ServerListItem } from '/@/shared/types/domain-types'; + +interface CreateRadioStationFormProps { + onCancel: () => void; +} + +export const CreateRadioStationForm = ({ onCancel }: CreateRadioStationFormProps) => { + const { t } = useTranslation(); + const mutation = useCreateRadioStation({}); + const server = useCurrentServer(); + + const form = useForm({ + initialValues: { + homepageUrl: '', + name: '', + streamUrl: '', + }, + }); + + const handleSubmit = form.onSubmit((values) => { + if (!server) return; + + mutation.mutate( + { + apiClientProps: { serverId: server.id }, + body: values, + }, + { + onError: (error) => { + toast.error({ + message: (error as Error).message, + title: t('error.genericError', { + postProcess: 'sentenceCase', + }) as string, + }); + }, + onSuccess: () => { + closeAllModals(); + }, + }, + ); + }); + + return ( +
+ + + + + + + {t('common.cancel', { postProcess: 'sentenceCase' })} + + + {t('common.create', { postProcess: 'sentenceCase' })} + + + +
+ ); +}; + +export const openCreateRadioStationModal = ( + server: null | ServerListItem, + e?: MouseEvent, +) => { + e?.stopPropagation(); + + if (!server) { + toast.error({ + message: t('common.error.noServer', { postProcess: 'sentenceCase' }) as string, + }); + return; + } + + openModal({ + children: , + title: t('action.createRadioStation', { postProcess: 'titleCase' }) as string, + }); +}; diff --git a/src/renderer/features/radio/components/edit-radio-station-form.tsx b/src/renderer/features/radio/components/edit-radio-station-form.tsx new file mode 100644 index 00000000..a9c809c6 --- /dev/null +++ b/src/renderer/features/radio/components/edit-radio-station-form.tsx @@ -0,0 +1,126 @@ +import { t } from 'i18next'; +import { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { useUpdateRadioStation } from '/@/renderer/features/radio/mutations/update-radio-station-mutation'; +import { useCurrentServer } from '/@/renderer/store'; +import { logFn } from '/@/renderer/utils/logger'; +import { logMsg } from '/@/renderer/utils/logger-message'; +import { Group } from '/@/shared/components/group/group'; +import { closeAllModals, openModal } from '/@/shared/components/modal/modal'; +import { ModalButton } from '/@/shared/components/modal/model-shared'; +import { Stack } from '/@/shared/components/stack/stack'; +import { TextInput } from '/@/shared/components/text-input/text-input'; +import { toast } from '/@/shared/components/toast/toast'; +import { useForm } from '/@/shared/hooks/use-form'; +import { + InternetRadioStation, + ServerListItem, + UpdateInternetRadioStationBody, +} from '/@/shared/types/domain-types'; + +interface EditRadioStationFormProps { + onCancel: () => void; + station: InternetRadioStation; +} + +export const EditRadioStationForm = ({ onCancel, station }: EditRadioStationFormProps) => { + const { t } = useTranslation(); + const mutation = useUpdateRadioStation({}); + const server = useCurrentServer(); + + const form = useForm({ + initialValues: { + homepageUrl: station.homepageUrl || '', + name: station.name, + streamUrl: station.streamUrl, + }, + }); + + const handleSubmit = form.onSubmit((values) => { + if (!server) return; + + mutation.mutate( + { + apiClientProps: { serverId: server.id }, + body: values, + query: { id: station.id }, + }, + { + onError: (error) => { + logFn.error(logMsg.other.error, { + meta: { error: error as Error }, + }); + + toast.error({ + message: (error as Error).message, + title: t('error.genericError', { + postProcess: 'sentenceCase', + }) as string, + }); + }, + onSuccess: () => { + closeAllModals(); + }, + }, + ); + }); + + return ( +
+ + + + + + + {t('common.cancel', { postProcess: 'sentenceCase' })} + + + {t('common.save', { postProcess: 'sentenceCase' })} + + + +
+ ); +}; + +export const openEditRadioStationModal = ( + station: InternetRadioStation, + server: null | ServerListItem, + e?: MouseEvent, +) => { + e?.stopPropagation(); + + if (!server) { + toast.error({ + message: t('common.error.noServer', { postProcess: 'sentenceCase' }) as string, + }); + return; + } + + openModal({ + children: , + title: t('common.edit', { postProcess: 'titleCase' }) as string, + }); +}; diff --git a/src/renderer/features/radio/components/radio-list-content.tsx b/src/renderer/features/radio/components/radio-list-content.tsx new file mode 100644 index 00000000..70c951e6 --- /dev/null +++ b/src/renderer/features/radio/components/radio-list-content.tsx @@ -0,0 +1,64 @@ +import { useQuery } from '@tanstack/react-query'; +import { Suspense, useEffect, useMemo } from 'react'; + +import { useListContext } from '/@/renderer/context/list-context'; +import { radioQueries } from '/@/renderer/features/radio/api/radio-api'; +import { RadioListItems } from '/@/renderer/features/radio/components/radio-list-items'; +import { useSearchTermFilter } from '/@/renderer/features/shared/hooks/use-search-term-filter'; +import { useSortByFilter } from '/@/renderer/features/shared/hooks/use-sort-by-filter'; +import { useSortOrderFilter } from '/@/renderer/features/shared/hooks/use-sort-order-filter'; +import { searchLibraryItems } from '/@/renderer/features/shared/utils'; +import { useCurrentServer } from '/@/renderer/store'; +import { sortRadioList } from '/@/shared/api/utils'; +import { ScrollArea } from '/@/shared/components/scroll-area/scroll-area'; +import { Spinner } from '/@/shared/components/spinner/spinner'; +import { Stack } from '/@/shared/components/stack/stack'; +import { LibraryItem, RadioListSort, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +export const RadioListContent = () => { + const server = useCurrentServer(); + const { setItemCount } = useListContext(); + const { searchTerm } = useSearchTermFilter(); + const { sortBy } = useSortByFilter(RadioListSort.NAME, ItemListKey.RADIO); + const { sortOrder } = useSortOrderFilter(SortOrder.ASC, ItemListKey.RADIO); + + const radioListQuery = useQuery({ + ...radioQueries.list({ + query: undefined, + serverId: server?.id || '', + }), + }); + + const filteredAndSortedRadioStations = useMemo(() => { + let stations = radioListQuery.data || []; + + if (searchTerm) { + stations = searchLibraryItems(stations, searchTerm, LibraryItem.RADIO_STATION); + } + + if (sortBy && sortOrder) { + stations = sortRadioList(stations, sortBy, sortOrder); + } + + return stations; + }, [radioListQuery.data, searchTerm, sortBy, sortOrder]); + + useEffect(() => { + setItemCount?.(filteredAndSortedRadioStations.length || 0); + }, [filteredAndSortedRadioStations.length, setItemCount]); + + if (radioListQuery.isLoading) { + return ; + } + + return ( + }> + + + + + + + ); +}; diff --git a/src/renderer/features/radio/components/radio-list-header-filters.tsx b/src/renderer/features/radio/components/radio-list-header-filters.tsx new file mode 100644 index 00000000..f3f4c2c9 --- /dev/null +++ b/src/renderer/features/radio/components/radio-list-header-filters.tsx @@ -0,0 +1,47 @@ +import { MouseEvent } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { openCreateRadioStationModal } from '/@/renderer/features/radio/components/create-radio-station-form'; +import { ListSortByDropdown } from '/@/renderer/features/shared/components/list-sort-by-dropdown'; +import { ListSortOrderToggleButton } from '/@/renderer/features/shared/components/list-sort-order-toggle-button'; +import { useCurrentServer, usePermissions } from '/@/renderer/store'; +import { Button } from '/@/shared/components/button/button'; +import { Divider } from '/@/shared/components/divider/divider'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { LibraryItem, RadioListSort, SortOrder } from '/@/shared/types/domain-types'; +import { ItemListKey } from '/@/shared/types/types'; + +export const RadioListHeaderFilters = () => { + const { t } = useTranslation(); + const server = useCurrentServer(); + const permissions = usePermissions(); + + const handleCreateRadioStationModal = (e: MouseEvent) => { + openCreateRadioStationModal(server, e); + }; + + return ( + + + + + + + {permissions.radio.create && ( + + + + )} + + ); +}; diff --git a/src/renderer/features/radio/components/radio-list-header.tsx b/src/renderer/features/radio/components/radio-list-header.tsx new file mode 100644 index 00000000..172252e7 --- /dev/null +++ b/src/renderer/features/radio/components/radio-list-header.tsx @@ -0,0 +1,40 @@ +import { useTranslation } from 'react-i18next'; + +import { PageHeader } from '/@/renderer/components/page-header/page-header'; +import { useListContext } from '/@/renderer/context/list-context'; +import { RadioListHeaderFilters } from '/@/renderer/features/radio/components/radio-list-header-filters'; +import { FilterBar } from '/@/renderer/features/shared/components/filter-bar'; +import { LibraryHeaderBar } from '/@/renderer/features/shared/components/library-header-bar'; +import { ListSearchInput } from '/@/renderer/features/shared/components/list-search-input'; +import { Group } from '/@/shared/components/group/group'; +import { Stack } from '/@/shared/components/stack/stack'; + +interface RadioListHeaderProps { + title?: string; +} + +export const RadioListHeader = ({ title }: RadioListHeaderProps) => { + const { t } = useTranslation(); + + const { itemCount } = useListContext(); + const pageTitle = title || t('page.radioList.title', { postProcess: 'titleCase' }); + + return ( + + + + {pageTitle} + + {itemCount} + + + + + + + + + + + ); +}; diff --git a/src/renderer/features/radio/components/radio-list-items.module.css b/src/renderer/features/radio/components/radio-list-items.module.css new file mode 100644 index 00000000..cd7bb1ea --- /dev/null +++ b/src/renderer/features/radio/components/radio-list-items.module.css @@ -0,0 +1,30 @@ +.radio-item { + cursor: pointer; + border-left: 3px solid transparent; + transition: background-color 0.15s ease; +} + +.radio-item:hover { + @mixin dark { + background-color: lighten(var(--theme-colors-surface), 1%); + } + + @mixin light { + background-color: darken(var(--theme-colors-surface), 1%); + } +} + +.radio-item-active { + border-left: 3px solid var(--theme-colors-primary); +} + +.radio-item-button { + all: unset; + flex: 1; + width: 100%; +} + +.radio-item-link { + color: inherit; + text-decoration: underline; +} diff --git a/src/renderer/features/radio/components/radio-list-items.tsx b/src/renderer/features/radio/components/radio-list-items.tsx new file mode 100644 index 00000000..5999925c --- /dev/null +++ b/src/renderer/features/radio/components/radio-list-items.tsx @@ -0,0 +1,168 @@ +import clsx from 'clsx'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import styles from './radio-list-items.module.css'; + +import { openEditRadioStationModal } from '/@/renderer/features/radio/components/edit-radio-station-form'; +import { + useRadioControls, + useRadioPlayer, +} from '/@/renderer/features/radio/hooks/use-radio-player'; +import { useDeleteRadioStation } from '/@/renderer/features/radio/mutations/delete-radio-station-mutation'; +import { useCurrentServer, usePermissions } from '/@/renderer/store'; +import { ActionIcon } from '/@/shared/components/action-icon/action-icon'; +import { Flex } from '/@/shared/components/flex/flex'; +import { Group } from '/@/shared/components/group/group'; +import { Icon } from '/@/shared/components/icon/icon'; +import { closeAllModals, ConfirmModal, openModal } from '/@/shared/components/modal/modal'; +import { Paper } from '/@/shared/components/paper/paper'; +import { Stack } from '/@/shared/components/stack/stack'; +import { Text } from '/@/shared/components/text/text'; +import { toast } from '/@/shared/components/toast/toast'; +import { InternetRadioStation } from '/@/shared/types/domain-types'; + +interface RadioListItemProps { + station: InternetRadioStation; +} + +interface RadioListItemsProps { + data: InternetRadioStation[]; +} + +const RadioListItem = ({ station }: RadioListItemProps) => { + const { t } = useTranslation(); + const { currentStreamUrl, isPlaying } = useRadioPlayer(); + const { play, stop } = useRadioControls(); + const server = useCurrentServer(); + const permissions = usePermissions(); + const deleteRadioStationMutation = useDeleteRadioStation({}); + + const isCurrentStation = currentStreamUrl === station.streamUrl; + const stationIsPlaying = isCurrentStation && isPlaying; + + const handleClick = () => { + if (stationIsPlaying) { + stop(); + } else { + play(station.streamUrl, station.name); + } + }; + + const handleEditClick = (e: React.MouseEvent) => { + e.stopPropagation(); + openEditRadioStationModal(station, server, e); + }; + + const handleDeleteClick = useCallback( + async (e: React.MouseEvent) => { + e.stopPropagation(); + + if (!server) return; + + openModal({ + children: ( + { + try { + await deleteRadioStationMutation.mutateAsync({ + apiClientProps: { serverId: server.id }, + query: { id: station.id }, + }); + + // Stop playback if this station is currently playing + if (isCurrentStation) { + stop(); + } + } catch (err: any) { + toast.error({ + message: err.message, + title: t('error.genericError', { + postProcess: 'sentenceCase', + }), + }); + } + + closeAllModals(); + }} + > + {t('common.areYouSure', { postProcess: 'sentenceCase' })} + + ), + title: t('common.delete', { postProcess: 'titleCase' }), + }); + }, + [deleteRadioStationMutation, isCurrentStation, server, station.id, stop, t], + ); + + return ( + + + + {(permissions.radio.edit || permissions.radio.delete) && ( + + {permissions.radio.edit && ( + + )} + {permissions.radio.delete && ( + + )} + + )} + + + ); +}; + +export const RadioListItems = ({ data }: RadioListItemsProps) => { + const items = useMemo( + () => data.map((station) => ), + [data], + ); + + return {items}; +}; diff --git a/src/renderer/features/radio/hooks/use-radio-player.ts b/src/renderer/features/radio/hooks/use-radio-player.ts new file mode 100644 index 00000000..997a8407 --- /dev/null +++ b/src/renderer/features/radio/hooks/use-radio-player.ts @@ -0,0 +1,376 @@ +import IcecastMetadataStats from 'icecast-metadata-stats'; +import isElectron from 'is-electron'; +import { useEffect, useRef } from 'react'; +import { createWithEqualityFn } from 'zustand/traditional'; + +import { usePlayerEvents } from '/@/renderer/features/player/audio-player/hooks/use-player-events'; +import { convertToLogVolume } from '/@/renderer/features/player/audio-player/utils/player-utils'; +import { + usePlaybackType, + usePlayerMuted, + usePlayerStoreBase, + usePlayerVolume, +} from '/@/renderer/store'; +import { toast } from '/@/shared/components/toast/toast'; +import { PlayerStatus, PlayerType } from '/@/shared/types/types'; + +export interface RadioMetadata { + artist: null | string; + title: null | string; +} + +interface RadioStore { + actions: { + pause: () => void; + play: (streamUrl?: string, stationName?: string) => void; + setCurrentStreamUrl: (currentStreamUrl: null | string) => void; + setIsPlaying: (isPlaying: boolean) => void; + setMetadata: (metadata: null | RadioMetadata) => void; + setStationName: (stationName: null | string) => void; + stop: () => void; + }; + currentStreamUrl: null | string; + isPlaying: boolean; + metadata: null | RadioMetadata; + stationName: null | string; +} + +export const useRadioStore = createWithEqualityFn((set) => ({ + actions: { + pause: () => { + set({ isPlaying: false }); + usePlayerStoreBase.getState().mediaPause(); + }, + play: (streamUrl?: string, stationName?: string) => { + set((state) => { + const newStreamUrl = streamUrl ?? state.currentStreamUrl; + const newStationName = stationName ?? state.stationName; + + if (!newStreamUrl) { + return state; + } + + // Reset metadata when switching stations (streamUrl changes) + const isSwitchingStation = newStreamUrl !== state.currentStreamUrl; + + usePlayerStoreBase.getState().mediaPlay(); + + return { + currentStreamUrl: newStreamUrl, + isPlaying: true, + metadata: isSwitchingStation ? null : state.metadata, + stationName: newStationName, + }; + }); + }, + setCurrentStreamUrl: (currentStreamUrl) => set({ currentStreamUrl }), + setIsPlaying: (isPlaying) => set({ isPlaying }), + setMetadata: (metadata) => set({ metadata }), + setStationName: (stationName) => set({ stationName }), + stop: () => { + set({ + currentStreamUrl: null, + isPlaying: false, + metadata: null, + stationName: null, + }); + usePlayerStoreBase.getState().mediaStop(); + }, + }, + currentStreamUrl: null, + isPlaying: false, + metadata: null, + stationName: null, +})); + +export const useIsPlayingRadio = () => useRadioStore((state) => state.isPlaying); + +export const useIsRadioActive = () => useRadioStore((state) => Boolean(state.currentStreamUrl)); + +export const useRadioPlayer = () => { + const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl); + const isPlaying = useRadioStore((state) => state.isPlaying); + const metadata = useRadioStore((state) => state.metadata); + const stationName = useRadioStore((state) => state.stationName); + + return { + currentStreamUrl, + isPlaying, + metadata, + stationName, + }; +}; + +export const useRadioControls = () => { + const { pause, play, stop } = useRadioStore((state) => state.actions); + + return { + pause, + play, + stop, + }; +}; + +const mpvPlayer = isElectron() ? window.api.mpvPlayer : null; +const mpvPlayerListener = isElectron() ? window.api.mpvPlayerListener : null; +const ipc = isElectron() ? window.api.ipc : null; + +export const useRadioAudioInstance = () => { + const { actions } = useRadioStore(); + const { setCurrentStreamUrl, setIsPlaying, setStationName } = actions; + const currentStreamUrl = useRadioStore((state) => state.currentStreamUrl); + const isPlaying = useRadioStore((state) => state.isPlaying); + const playbackType = usePlaybackType(); + const volume = usePlayerVolume(); + const isMuted = usePlayerMuted(); + const audioRef = useRef(null); + const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer; + + // Handle mpv playback + useEffect(() => { + if (!isUsingMpv || !mpvPlayer) { + return; + } + + if (currentStreamUrl) { + mpvPlayer.setQueue(currentStreamUrl, undefined, !isPlaying); + } else { + mpvPlayer.setQueue(undefined, undefined, true); + } + }, [ + currentStreamUrl, + isPlaying, + isUsingMpv, + setIsPlaying, + setCurrentStreamUrl, + setStationName, + ]); + + useEffect(() => { + if (!isUsingMpv || !mpvPlayerListener || !ipc) { + return; + } + + const handleMpvPlay = () => { + setIsPlaying(true); + }; + + const handleMpvPause = () => { + setIsPlaying(false); + }; + + const handleMpvStop = () => { + setIsPlaying(false); + setCurrentStreamUrl(null); + setStationName(null); + }; + + mpvPlayerListener.rendererPlay(handleMpvPlay); + mpvPlayerListener.rendererPause(handleMpvPause); + mpvPlayerListener.rendererStop(handleMpvStop); + + return () => { + ipc.removeAllListeners('renderer-player-play'); + ipc.removeAllListeners('renderer-player-pause'); + ipc.removeAllListeners('renderer-player-stop'); + }; + }, [isUsingMpv, setIsPlaying, setCurrentStreamUrl, setStationName]); + + // Handle web playback + useEffect(() => { + if (isUsingMpv) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + return; + } + + if (currentStreamUrl && isPlaying) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + } + + const audio = new Audio(currentStreamUrl); + audioRef.current = audio; + + const linearVolume = volume / 100; + const logVolume = convertToLogVolume(linearVolume); + audio.volume = logVolume; + audio.muted = isMuted; + + audio.addEventListener('play', () => { + setIsPlaying(true); + }); + + audio.addEventListener('pause', () => { + setIsPlaying(false); + }); + + audio.addEventListener('ended', () => { + setIsPlaying(false); + setCurrentStreamUrl(null); + setStationName(null); + }); + + audio.addEventListener('error', (error) => { + console.error('Radio stream error:', error); + }); + + // Attempt to play + audio.play().catch((error) => { + console.error('Failed to play audio:', error); + setIsPlaying(false); + setCurrentStreamUrl(null); + setStationName(null); + toast.error({ message: 'Failed to play radio stream' }); + }); + } else if (!currentStreamUrl || !isPlaying) { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + } + + return () => { + if (audioRef.current) { + audioRef.current.pause(); + audioRef.current.src = ''; + audioRef.current = null; + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + currentStreamUrl, + isPlaying, + isUsingMpv, + setIsPlaying, + setCurrentStreamUrl, + setStationName, + ]); + + useEffect(() => { + if (isUsingMpv || !audioRef.current) { + return; + } + + const linearVolume = volume / 100; + const logVolume = convertToLogVolume(linearVolume); + audioRef.current.volume = logVolume; + audioRef.current.muted = isMuted; + }, [volume, isMuted, isUsingMpv]); + + usePlayerEvents( + { + onPlayerStatus: (properties, prev) => { + const radioState = useRadioStore.getState(); + if (!radioState.currentStreamUrl) { + return; + } + + const { status } = properties; + const { status: prevStatus } = prev; + + if (status === prevStatus) { + return; + } + + if (status === PlayerStatus.PLAYING && prevStatus === PlayerStatus.PAUSED) { + actions.play(); + } else if (status === PlayerStatus.PAUSED && prevStatus === PlayerStatus.PLAYING) { + actions.pause(); + } + }, + }, + [actions], + ); +}; + +export const useRadioMetadata = () => { + const { actions, currentStreamUrl } = useRadioStore(); + const { setMetadata } = actions; + const playbackType = usePlaybackType(); + const isUsingMpv = playbackType === PlayerType.LOCAL && mpvPlayer; + + useEffect(() => { + if (!currentStreamUrl) { + setMetadata(null); + return; + } + + // If using mpv, fetch metadata from mpv periodically + if (isUsingMpv && mpvPlayer) { + let intervalId: NodeJS.Timeout | null = null; + + const fetchMpvMetadata = async () => { + try { + const metadata = await mpvPlayer.getStreamMetadata(); + setMetadata(metadata); + } catch { + // Ignore error + } + }; + + intervalId = setInterval(fetchMpvMetadata, 5000); + + return () => { + if (intervalId) { + clearInterval(intervalId); + } + setMetadata(null); + }; + } + + // Otherwise, use IcecastMetadataStats for web player + let statsListener: IcecastMetadataStats | null = null; + + try { + statsListener = new IcecastMetadataStats(currentStreamUrl, { + interval: 12, + onStats: (stats) => { + // Parse ICY metadata - typically in format "Artist - Title" or just "Title" + let streamTitle: null | string = null; + + if (stats.StreamTitle) { + streamTitle = stats.StreamTitle; + } else if (stats.icy?.StreamTitle) { + streamTitle = stats.icy.StreamTitle; + } + + // Parse the combined format into title and artist + let artist: null | string = null; + let title: null | string = null; + + if (streamTitle) { + // Try to parse "Artist - Title" format + const match = streamTitle.match(/^(.*?)\s*[-–—]\s*(.+)$/); + if (match) { + artist = match[1].trim() || null; + title = match[2].trim() || null; + } else { + // If no separator found, treat the whole thing as title + title = streamTitle; + } + } + + setMetadata(title || artist ? { artist, title } : null); + }, + sources: ['icy'], + }); + + statsListener.start(); + } catch { + setMetadata(null); + } + + return () => { + if (statsListener) { + statsListener.stop(); + } + setMetadata(null); + }; + }, [currentStreamUrl, setMetadata, isUsingMpv]); +}; diff --git a/src/renderer/features/radio/mutations/create-radio-station-mutation.ts b/src/renderer/features/radio/mutations/create-radio-station-mutation.ts new file mode 100644 index 00000000..e9bb887b --- /dev/null +++ b/src/renderer/features/radio/mutations/create-radio-station-mutation.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { + CreateInternetRadioStationArgs, + CreateInternetRadioStationResponse, +} from '/@/shared/types/domain-types'; + +export const useCreateRadioStation = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation< + CreateInternetRadioStationResponse, + AxiosError, + CreateInternetRadioStationArgs, + null + >({ + mutationFn: (args) => { + return api.controller.createInternetRadioStation({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_args, variables) => { + queryClient.invalidateQueries({ + exact: false, + queryKey: queryKeys.radio.list(variables.apiClientProps.serverId), + }); + }, + ...options, + }); +}; diff --git a/src/renderer/features/radio/mutations/delete-radio-station-mutation.ts b/src/renderer/features/radio/mutations/delete-radio-station-mutation.ts new file mode 100644 index 00000000..7d46a824 --- /dev/null +++ b/src/renderer/features/radio/mutations/delete-radio-station-mutation.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { + DeleteInternetRadioStationArgs, + DeleteInternetRadioStationResponse, +} from '/@/shared/types/domain-types'; + +export const useDeleteRadioStation = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation< + DeleteInternetRadioStationResponse, + AxiosError, + DeleteInternetRadioStationArgs, + null + >({ + mutationFn: (args) => { + return api.controller.deleteInternetRadioStation({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_args, variables) => { + queryClient.invalidateQueries({ + exact: false, + queryKey: queryKeys.radio.list(variables.apiClientProps.serverId), + }); + }, + ...options, + }); +}; diff --git a/src/renderer/features/radio/mutations/update-radio-station-mutation.ts b/src/renderer/features/radio/mutations/update-radio-station-mutation.ts new file mode 100644 index 00000000..a4fda293 --- /dev/null +++ b/src/renderer/features/radio/mutations/update-radio-station-mutation.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { AxiosError } from 'axios'; + +import { api } from '/@/renderer/api'; +import { queryKeys } from '/@/renderer/api/query-keys'; +import { MutationHookArgs } from '/@/renderer/lib/react-query'; +import { + UpdateInternetRadioStationArgs, + UpdateInternetRadioStationResponse, +} from '/@/shared/types/domain-types'; + +export const useUpdateRadioStation = (args: MutationHookArgs) => { + const { options } = args || {}; + const queryClient = useQueryClient(); + + return useMutation< + UpdateInternetRadioStationResponse, + AxiosError, + UpdateInternetRadioStationArgs, + null + >({ + mutationFn: (args) => { + return api.controller.updateInternetRadioStation({ + ...args, + apiClientProps: { serverId: args.apiClientProps.serverId }, + }); + }, + onSuccess: (_args, variables) => { + queryClient.invalidateQueries({ + exact: false, + queryKey: queryKeys.radio.list(variables.apiClientProps.serverId), + }); + }, + ...options, + }); +}; diff --git a/src/renderer/features/radio/routes/radio-list-route.tsx b/src/renderer/features/radio/routes/radio-list-route.tsx new file mode 100644 index 00000000..dc1e06cb --- /dev/null +++ b/src/renderer/features/radio/routes/radio-list-route.tsx @@ -0,0 +1,42 @@ +import { useMemo, useState } from 'react'; + +import { ListContext } from '/@/renderer/context/list-context'; +import { RadioListContent } from '/@/renderer/features/radio/components/radio-list-content'; +import { RadioListHeader } from '/@/renderer/features/radio/components/radio-list-header'; +import { AnimatedPage } from '/@/renderer/features/shared/components/animated-page'; +import { PageErrorBoundary } from '/@/renderer/features/shared/components/page-error-boundary'; +import { ItemListKey } from '/@/shared/types/types'; + +const RadioListRoute = () => { + const pageKey = ItemListKey.RADIO; + + const [itemCount, setItemCount] = useState(undefined); + + const providerValue = useMemo(() => { + return { + id: undefined, + itemCount, + pageKey, + setItemCount, + }; + }, [itemCount, pageKey, setItemCount]); + + return ( + + + + + + + ); +}; + +const RadioListRouteWithBoundary = () => { + return ( + + + + ); +}; + +export default RadioListRouteWithBoundary; diff --git a/src/renderer/features/radio/store/radio-store.ts b/src/renderer/features/radio/store/radio-store.ts new file mode 100644 index 00000000..ffd1cc05 --- /dev/null +++ b/src/renderer/features/radio/store/radio-store.ts @@ -0,0 +1,115 @@ +import merge from 'lodash/merge'; +import { nanoid } from 'nanoid/non-secure'; +import { devtools, persist } from 'zustand/middleware'; +import { immer } from 'zustand/middleware/immer'; +import { createWithEqualityFn } from 'zustand/traditional'; + +import { InternetRadioStation } from '/@/shared/types/domain-types'; + +export interface RadioStoreSlice extends RadioStoreState { + actions: { + createStation: ( + serverId: string, + station: Omit, + ) => InternetRadioStation; + deleteStation: (serverId: string, stationId: string) => void; + getStation: (serverId: string, stationId: string) => InternetRadioStation | null; + getStations: (serverId: string) => InternetRadioStation[]; + updateStation: ( + serverId: string, + stationId: string, + updates: Partial, + ) => void; + }; +} + +export interface RadioStoreState { + stations: Record>; +} + +const initialState: RadioStoreState = { + stations: {}, +}; + +export const useRadioStore = createWithEqualityFn()( + persist( + devtools( + immer((set, get) => ({ + ...initialState, + actions: { + createStation: (serverId, station) => { + const id = nanoid(); + const newStation: InternetRadioStation = { + ...station, + id, + }; + + set((state) => { + if (!state.stations[serverId]) { + state.stations[serverId] = {}; + } + state.stations[serverId][id] = newStation; + }); + + return newStation; + }, + deleteStation: (serverId, stationId) => { + set((state) => { + if (state.stations[serverId]) { + delete state.stations[serverId][stationId]; + // Clean up empty server entries + if (Object.keys(state.stations[serverId]).length === 0) { + delete state.stations[serverId]; + } + } + }); + }, + getStation: (serverId, stationId) => { + const state = get(); + return state.stations[serverId]?.[stationId] || null; + }, + getStations: (serverId) => { + const state = get(); + const serverStations = state.stations[serverId]; + if (!serverStations) { + return []; + } + return Object.values(serverStations); + }, + updateStation: (serverId, stationId, updates) => { + set((state) => { + if (state.stations[serverId]?.[stationId]) { + state.stations[serverId][stationId] = { + ...state.stations[serverId][stationId], + ...updates, + }; + } + }); + }, + }, + })), + { name: 'store_radio' }, + ), + { + merge: (persistedState, currentState) => merge(currentState, persistedState), + name: 'store_radio', + version: 1, + }, + ), +); + +export const useRadioStoreActions = () => useRadioStore((state) => state.actions); + +export const useRadioStations = (serverId: string) => { + return useRadioStore((state) => { + const serverStations = state.stations[serverId]; + if (!serverStations) { + return []; + } + return Object.values(serverStations); + }); +}; + +export const useRadioStation = (serverId: string, stationId: string) => { + return useRadioStore((state) => state.stations[serverId]?.[stationId] || null); +}; diff --git a/src/renderer/features/settings/components/general/sidebar-reorder.tsx b/src/renderer/features/settings/components/general/sidebar-reorder.tsx index da39df92..dd3da6a9 100644 --- a/src/renderer/features/settings/components/general/sidebar-reorder.tsx +++ b/src/renderer/features/settings/components/general/sidebar-reorder.tsx @@ -17,6 +17,7 @@ const SIDEBAR_ITEMS: Array<[string, string]> = [ ['Home', 'page.sidebar.home'], ['Now Playing', 'page.sidebar.nowPlaying'], ['Playlists', 'page.sidebar.playlists'], + ['Radio', 'page.sidebar.radio'], ['Search', 'page.sidebar.search'], ['Settings', 'page.sidebar.settings'], ['Tracks', 'page.sidebar.tracks'], diff --git a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx index 26a7fa79..0024a1f0 100644 --- a/src/renderer/features/shared/components/list-sort-by-dropdown.tsx +++ b/src/renderer/features/shared/components/list-sort-by-dropdown.tsx @@ -12,6 +12,7 @@ import { GenreListSort, LibraryItem, PlaylistListSort, + RadioListSort, ServerType, SongListSort, SortOrder, @@ -802,6 +803,47 @@ const PLAYLIST_LIST_FILTERS: Partial< ], }; +const RADIO_LIST_FILTERS: Partial< + Record> +> = { + [ServerType.JELLYFIN]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: RadioListSort.ID, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: RadioListSort.NAME, + }, + ], + [ServerType.NAVIDROME]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: RadioListSort.ID, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: RadioListSort.NAME, + }, + ], + [ServerType.SUBSONIC]: [ + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.id', { postProcess: 'titleCase' }), + value: RadioListSort.ID, + }, + { + defaultOrder: SortOrder.ASC, + name: i18n.t('filter.name', { postProcess: 'titleCase' }), + value: RadioListSort.NAME, + }, + ], +}; + const FILTERS: Partial> = { [LibraryItem.ALBUM]: ALBUM_LIST_FILTERS, [LibraryItem.ALBUM_ARTIST]: ALBUM_ARTIST_LIST_FILTERS, @@ -810,5 +852,6 @@ const FILTERS: Partial> = { [LibraryItem.GENRE]: GENRE_LIST_FILTERS, [LibraryItem.PLAYLIST]: PLAYLIST_LIST_FILTERS, [LibraryItem.PLAYLIST_SONG]: PLAYLIST_SONG_LIST_FILTERS, + [LibraryItem.RADIO_STATION]: RADIO_LIST_FILTERS, [LibraryItem.SONG]: SONG_LIST_FILTERS, }; diff --git a/src/renderer/features/shared/utils.ts b/src/renderer/features/shared/utils.ts index ccc4779b..c0ebedad 100644 --- a/src/renderer/features/shared/utils.ts +++ b/src/renderer/features/shared/utils.ts @@ -7,6 +7,7 @@ import { AlbumArtist, Artist, Genre, + InternetRadioStation, LibraryItem, Playlist, QueueSong, @@ -97,7 +98,15 @@ interface CreateFuseOptions { threshold?: number; } -type FuseSearchableItem = Album | AlbumArtist | Artist | Genre | Playlist | QueueSong | Song; +type FuseSearchableItem = + | Album + | AlbumArtist + | Artist + | Genre + | InternetRadioStation + | Playlist + | QueueSong + | Song; export const createFuseForLibraryItem = ( items: T[], @@ -171,6 +180,7 @@ export const createFuseForLibraryItem = ( case LibraryItem.ARTIST: case LibraryItem.GENRE: + case LibraryItem.RADIO_STATION: break; case LibraryItem.PLAYLIST: { diff --git a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx index aecdf547..7c60adbb 100644 --- a/src/renderer/features/sidebar/components/collapsed-sidebar.tsx +++ b/src/renderer/features/sidebar/components/collapsed-sidebar.tsx @@ -38,6 +38,7 @@ export const CollapsedSidebar = () => { Home: t('page.sidebar.home', { postProcess: 'titleCase' }), 'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }), Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }), + Radio: t('page.sidebar.radio', { postProcess: 'titleCase' }), Search: t('page.sidebar.search', { postProcess: 'titleCase' }), Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }), Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }), diff --git a/src/renderer/features/sidebar/components/sidebar-icon.tsx b/src/renderer/features/sidebar/components/sidebar-icon.tsx index bf088b8e..237e2118 100644 --- a/src/renderer/features/sidebar/components/sidebar-icon.tsx +++ b/src/renderer/features/sidebar/components/sidebar-icon.tsx @@ -15,6 +15,8 @@ import { RiPlayLine, RiPlayListFill, RiPlayListLine, + RiRadioFill, + RiRadioLine, RiSearchFill, RiSearchLine, RiSettings2Fill, @@ -64,6 +66,9 @@ export const SidebarIcon = ({ active, route, size }: SidebarIconProps) => { case AppRoute.PLAYLISTS: if (isActive) return ; return ; + case AppRoute.RADIO: + if (isActive) return ; + return ; case AppRoute.SETTINGS: if (isActive) return ; return ; diff --git a/src/renderer/features/sidebar/components/sidebar.tsx b/src/renderer/features/sidebar/components/sidebar.tsx index 45bb1989..8da8eb65 100644 --- a/src/renderer/features/sidebar/components/sidebar.tsx +++ b/src/renderer/features/sidebar/components/sidebar.tsx @@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next'; import styles from './sidebar.module.css'; import { ContextMenuController } from '/@/renderer/features/context-menu/context-menu-controller'; +import { useRadioStore } from '/@/renderer/features/radio/hooks/use-radio-player'; import { ActionBar } from '/@/renderer/features/sidebar/components/action-bar'; import { ServerSelector } from '/@/renderer/features/sidebar/components/server-selector'; import { SidebarIcon } from '/@/renderer/features/sidebar/components/sidebar-icon'; @@ -52,6 +53,7 @@ export const Sidebar = () => { Home: t('page.sidebar.home', { postProcess: 'titleCase' }), 'Now Playing': t('page.sidebar.nowPlaying', { postProcess: 'titleCase' }), Playlists: t('page.sidebar.playlists', { postProcess: 'titleCase' }), + Radio: t('page.sidebar.radio', { postProcess: 'titleCase' }), Search: t('page.sidebar.search', { postProcess: 'titleCase' }), Settings: t('page.sidebar.settings', { postProcess: 'titleCase' }), Tracks: t('page.sidebar.tracks', { postProcess: 'titleCase' }), @@ -61,7 +63,9 @@ export const Sidebar = () => { const { sidebarItems } = useGeneralSettings(); const { windowBarStyle } = useWindowSettings(); - const showImage = useAppStore((state) => state.sidebar.image); + const sidebarImageEnabled = useAppStore((state) => state.sidebar.image); + const isRadioPlaying = useRadioStore((state) => state.isPlaying); + const showImage = sidebarImageEnabled && !isRadioPlaying; const sidebarItemsWithRoute: SidebarItemType[] = useMemo(() => { if (!sidebarItems) return []; diff --git a/src/renderer/layouts/window-bar.tsx b/src/renderer/layouts/window-bar.tsx index 48d5c744..2a29b81f 100644 --- a/src/renderer/layouts/window-bar.tsx +++ b/src/renderer/layouts/window-bar.tsx @@ -12,6 +12,7 @@ import macMinHover from './assets/min-mac-hover.png'; import macMin from './assets/min-mac.png'; import styles from './window-bar.module.css'; +import { useRadioPlayer } from '/@/renderer/features/radio/hooks/use-radio-player'; import { useAppStore, usePlayerData, usePlayerStatus, useWindowSettings } from '/@/renderer/store'; import { Text } from '/@/shared/components/text/text'; import { Platform, PlayerStatus } from '/@/shared/types/types'; @@ -128,6 +129,8 @@ export const WindowBar = () => { const handleMinimize = () => minimize(); const { currentSong, index, queueLength } = usePlayerData(); + const { isPlaying: isRadioPlaying, metadata, stationName } = useRadioPlayer(); + const isRadioActive = Boolean(stationName || metadata); const [max, setMax] = useState(localSettings?.env.START_MAXIMIZED || false); const handleMaximize = useCallback(() => { @@ -142,16 +145,49 @@ export const WindowBar = () => { const handleClose = useCallback(() => close(), []); const title = useMemo(() => { + const privateModeString = privateMode ? '(Private mode)' : ''; + + // Show radio information if radio is active + if (isRadioActive) { + const radioStatusString = !isRadioPlaying ? '(Paused) ' : ''; + const radioTitle = stationName || 'Radio'; + + // Format metadata: show title, or combine artist and title if both available + let radioMetadata = ''; + if (metadata) { + if (metadata.title && metadata.artist) { + radioMetadata = ` — ${metadata.artist} — ${metadata.title}`; + } else if (metadata.title) { + radioMetadata = ` — ${metadata.title}`; + } else if (metadata.artist) { + radioMetadata = ` — ${metadata.artist}`; + } + } + + return `${radioStatusString}${radioTitle}${radioMetadata} — Feishin${privateMode ? ` ${privateModeString}` : ''}`; + } + + // Show regular song information const statusString = playerStatus === PlayerStatus.PAUSED ? '(Paused) ' : ''; const queueString = queueLength ? `(${index + 1} / ${queueLength}) ` : ''; - const privateModeString = privateMode ? '(Private mode)' : ''; const title = `${ queueLength ? `${statusString}${queueString}${currentSong?.name}${currentSong?.artistName ? ` — ${currentSong?.artistName} — Feishin` : ''}` : 'Feishin' }${privateMode ? ` ${privateModeString}` : ''}`; return title; - }, [currentSong?.artistName, currentSong?.name, index, playerStatus, privateMode, queueLength]); + }, [ + currentSong?.artistName, + currentSong?.name, + index, + isRadioActive, + isRadioPlaying, + metadata, + playerStatus, + privateMode, + queueLength, + stationName, + ]); useEffect(() => { document.title = title; diff --git a/src/renderer/router/app-router.tsx b/src/renderer/router/app-router.tsx index e7c6dca1..d1a1c743 100644 --- a/src/renderer/router/app-router.tsx +++ b/src/renderer/router/app-router.tsx @@ -71,6 +71,8 @@ const GenreDetailRoute = lazy( const FolderListRoute = lazy(() => import('/@/renderer/features/folders/routes/folder-list-route')); +const RadioListRoute = lazy(() => import('/@/renderer/features/radio/routes/radio-list-route')); + const SearchRoute = lazy(() => import('/@/renderer/features/search/routes/search-route')); const FavoritesRoute = lazy(() => import('/@/renderer/features/favorites/routes/favorites-route')); @@ -154,6 +156,7 @@ export const AppRouter = () => { element={} path={AppRoute.PLAYLISTS} /> + } path={AppRoute.RADIO} /> } path={AppRoute.PLAYLISTS_DETAIL_SONGS} diff --git a/src/renderer/router/routes.ts b/src/renderer/router/routes.ts index 35b40a35..894fddd9 100644 --- a/src/renderer/router/routes.ts +++ b/src/renderer/router/routes.ts @@ -25,6 +25,7 @@ export enum AppRoute { PLAYING = '/playing', PLAYLISTS = '/playlists', PLAYLISTS_DETAIL_SONGS = '/playlists/:playlistId/songs', + RADIO = '/radio', SEARCH = '/search/:itemType', SERVERS = '/servers', SETTINGS = '/settings', diff --git a/src/renderer/store/auth.store.ts b/src/renderer/store/auth.store.ts index 5aaf3d01..1dfdf869 100644 --- a/src/renderer/store/auth.store.ts +++ b/src/renderer/store/auth.store.ts @@ -163,6 +163,11 @@ export const usePermissions = () => { playlists: { editPublic: isAdmin, }, + radio: { + create: isAdmin, + delete: isAdmin, + edit: isAdmin, + }, userId: userId, }; }; diff --git a/src/renderer/store/settings.store.ts b/src/renderer/store/settings.store.ts index e690dc4b..a10430de 100644 --- a/src/renderer/store/settings.store.ts +++ b/src/renderer/store/settings.store.ts @@ -639,6 +639,12 @@ export const sidebarItems: SidebarItemType[] = [ label: i18n.t('page.sidebar.playlists'), route: AppRoute.PLAYLISTS, }, + { + disabled: false, + id: 'Radio', + label: i18n.t('page.sidebar.radio'), + route: AppRoute.RADIO, + }, { disabled: true, id: 'Settings', @@ -1500,10 +1506,19 @@ export const useSettingsStore = createWithEqualityFn()( state.lists['sidequeue']?.table.columns.push(...columns); } + if (version <= 15) { + state.general.sidebarItems.push({ + disabled: false, + id: 'Radio', + label: i18n.t('page.sidebar.radio'), + route: AppRoute.RADIO, + }); + } + return persistedState; }, name: 'store_settings', - version: 15, + version: 16, }, ), ); diff --git a/src/shared/api/subsonic/subsonic-normalize.ts b/src/shared/api/subsonic/subsonic-normalize.ts index 40aff4ca..9a7ac3a4 100644 --- a/src/shared/api/subsonic/subsonic-normalize.ts +++ b/src/shared/api/subsonic/subsonic-normalize.ts @@ -7,6 +7,7 @@ import { ExplicitStatus, Folder, Genre, + InternetRadioStation, LibraryItem, Playlist, RelatedArtist, @@ -391,11 +392,23 @@ const normalizeFolder = ( }; }; +const normalizeInternetRadioStation = ( + item: z.infer, +): InternetRadioStation => { + return { + homepageUrl: item.homepageUrl || null, + id: item.id, + name: item.name, + streamUrl: item.streamUrl, + }; +}; + export const ssNormalize = { album: normalizeAlbum, albumArtist: normalizeAlbumArtist, folder: normalizeFolder, genre: normalizeGenre, + internetRadioStation: normalizeInternetRadioStation, playlist: normalizePlaylist, song: normalizeSong, }; diff --git a/src/shared/api/subsonic/subsonic-types.ts b/src/shared/api/subsonic/subsonic-types.ts index 229a5418..092e8bd9 100644 --- a/src/shared/api/subsonic/subsonic-types.ts +++ b/src/shared/api/subsonic/subsonic-types.ts @@ -654,6 +654,44 @@ const playQueueByIndex = z.object({ }), }); +const internetRadioStation = z.object({ + homepageUrl: z.string().optional(), + id: z.string(), + name: z.string(), + streamUrl: z.string(), +}); + +const deleteInternetRadioStationParameters = z.object({ + id: z.string(), +}); + +const deleteInternetRadioStation = z.null(); + +const createInternetRadioStationParameters = z.object({ + homepageUrl: z.string().optional(), + name: z.string(), + streamUrl: z.string(), +}); + +const createInternetRadioStation = z.null(); + +const updateInternetRadioStationParameters = z.object({ + homepageUrl: z.string().optional(), + id: z.string(), + name: z.string(), + streamUrl: z.string(), +}); + +const updateInternetRadioStation = z.null(); + +const getInternetRadioStations = z.object({ + internetRadioStations: z + .object({ + internetRadioStation: z.array(internetRadioStation), + }) + .optional(), +}); + export const ssType = { _parameters: { albumInfo: albumInfoParameters, @@ -661,7 +699,9 @@ export const ssType = { artistInfo: artistInfoParameters, authenticate: authenticateParameters, createFavorite: createFavoriteParameters, + createInternetRadioStation: createInternetRadioStationParameters, createPlaylist: createPlaylistParameters, + deleteInternetRadioStation: deleteInternetRadioStationParameters, deletePlaylist: deletePlaylistParameters, getAlbum: getAlbumParameters, getAlbumList2: getAlbumList2Parameters, @@ -686,6 +726,7 @@ export const ssType = { similarSongs: similarSongsParameters, structuredLyrics: structuredLyricsParameters, topSongsList: topSongsListParameters, + updateInternetRadioStation: updateInternetRadioStationParameters, updatePlaylist: updatePlaylistParameters, user: userParameters, }, @@ -701,7 +742,9 @@ export const ssType = { authenticate, baseResponse, createFavorite, + createInternetRadioStation, createPlaylist, + deleteInternetRadioStation, directory, genre, getAlbum, @@ -710,12 +753,14 @@ export const ssType = { getArtists, getGenres, getIndexes, + getInternetRadioStations, getMusicDirectory, getPlaylist, getPlaylists, getSong, getSongsByGenre, getStarred, + internetRadioStation, musicFolderList, ping, playlist, @@ -733,6 +778,7 @@ export const ssType = { song, structuredLyrics, topSongsList, + updateInternetRadioStation, user, }, }; diff --git a/src/shared/api/utils.ts b/src/shared/api/utils.ts index 049c8ae6..59368793 100644 --- a/src/shared/api/utils.ts +++ b/src/shared/api/utils.ts @@ -12,7 +12,9 @@ import { AlbumArtistListSort, AlbumListSort, ArtistListSort, + InternetRadioStation, LibraryItem, + RadioListSort, ServerListItem, Song, SongListSort, @@ -365,6 +367,7 @@ export const sortAlbumArtistList = ( return results; }; + export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: SortOrder) => { let results = albums; @@ -414,3 +417,29 @@ export const sortAlbumList = (albums: Album[], sortBy: AlbumListSort, sortOrder: return results; }; + +export const sortRadioList = ( + stations: InternetRadioStation[], + sortBy: RadioListSort, + sortOrder: SortOrder, +) => { + let results = stations; + + const order = sortOrder === SortOrder.ASC ? 'asc' : 'desc'; + + switch (sortBy) { + case RadioListSort.ID: + results = [...results]; + if (order === 'desc') { + results.reverse(); + } + break; + case RadioListSort.NAME: + results = orderBy(results, [(v) => v.name.toLowerCase()], [order]); + break; + default: + break; + } + + return results; +}; diff --git a/src/shared/types/domain-types.ts b/src/shared/types/domain-types.ts index 70893208..7c3fd004 100644 --- a/src/shared/types/domain-types.ts +++ b/src/shared/types/domain-types.ts @@ -29,6 +29,7 @@ export enum LibraryItem { PLAYLIST = 'playlist', PLAYLIST_SONG = 'playlistSong', QUEUE_SONG = 'queueSong', + RADIO_STATION = 'radioStation', SONG = 'song', } @@ -861,8 +862,6 @@ export const artistListSortMap: ArtistListSortMap = { }, }; -// Artist Detail - export enum PlaylistListSort { DURATION = 'duration', NAME = 'name', @@ -872,6 +871,11 @@ export enum PlaylistListSort { UPDATED_AT = 'updatedAt', } +export enum RadioListSort { + ID = 'id', + NAME = 'name', +} + export type AddToPlaylistArgs = BaseEndpointArgs & { body: AddToPlaylistBody; query: AddToPlaylistQuery; @@ -888,6 +892,18 @@ export type AddToPlaylistQuery = { // Add to playlist export type AddToPlaylistResponse = null | undefined; +export type CreateInternetRadioStationArgs = BaseEndpointArgs & { + body: CreateInternetRadioStationBody; +}; + +export type CreateInternetRadioStationBody = { + homepageUrl?: string; + name: string; + streamUrl: string; +}; + +export type CreateInternetRadioStationResponse = null | undefined; + export type CreatePlaylistArgs = BaseEndpointArgs & { body: CreatePlaylistBody }; export type CreatePlaylistBody = { @@ -903,6 +919,16 @@ export type CreatePlaylistBody = { // Create Playlist export type CreatePlaylistResponse = undefined | { id: string }; +export type DeleteInternetRadioStationArgs = BaseEndpointArgs & { + query: DeleteInternetRadioStationQuery; +}; + +export type DeleteInternetRadioStationQuery = { + id: string; +}; + +export type DeleteInternetRadioStationResponse = null | undefined; + export type DeletePlaylistArgs = BaseEndpointArgs & { query: DeletePlaylistQuery; }; @@ -922,6 +948,17 @@ export type FavoriteQuery = { // Favorite export type FavoriteResponse = null | undefined; +export type GetInternetRadioStationsArgs = BaseEndpointArgs; + +export type GetInternetRadioStationsResponse = InternetRadioStation[]; + +export type InternetRadioStation = { + homepageUrl?: null | string; + id: string; + name: string; + streamUrl: string; +}; + export type PlaylistListArgs = BaseEndpointArgs & { query: PlaylistListQuery }; export type PlaylistListCountArgs = BaseEndpointArgs & { query: ListCountQuery }; @@ -989,6 +1026,23 @@ export type ShareItemBody = { // Sharing export type ShareItemResponse = undefined | { id: string }; +export type UpdateInternetRadioStationArgs = BaseEndpointArgs & { + body: UpdateInternetRadioStationBody; + query: UpdateInternetRadioStationQuery; +}; + +export type UpdateInternetRadioStationBody = { + homepageUrl?: string; + name: string; + streamUrl: string; +}; + +export type UpdateInternetRadioStationQuery = { + id: string; +}; + +export type UpdateInternetRadioStationResponse = null | undefined; + export type UpdatePlaylistArgs = BaseEndpointArgs & { body: UpdatePlaylistBody; query: UpdatePlaylistQuery; @@ -1265,8 +1319,14 @@ export type ControllerEndpoint = { body: { legacy?: boolean; password: string; username: string }, ) => Promise; createFavorite: (args: FavoriteArgs) => Promise; + createInternetRadioStation: ( + args: CreateInternetRadioStationArgs, + ) => Promise; createPlaylist: (args: CreatePlaylistArgs) => Promise; deleteFavorite: (args: FavoriteArgs) => Promise; + deleteInternetRadioStation: ( + args: DeleteInternetRadioStationArgs, + ) => Promise; deletePlaylist: (args: DeletePlaylistArgs) => Promise; getAlbumArtistDetail: (args: AlbumArtistDetailArgs) => Promise; getAlbumArtistList: (args: AlbumArtistListArgs) => Promise; @@ -1280,6 +1340,9 @@ export type ControllerEndpoint = { getDownloadUrl: (args: DownloadArgs) => string; getFolder: (args: FolderArgs) => Promise; getGenreList: (args: GenreListArgs) => Promise; + getInternetRadioStations: ( + args: GetInternetRadioStationsArgs, + ) => Promise; getLyrics?: (args: LyricsArgs) => Promise; getMusicFolderList: (args: MusicFolderListArgs) => Promise; getPlaylistDetail: (args: PlaylistDetailArgs) => Promise; @@ -1309,6 +1372,9 @@ export type ControllerEndpoint = { search: (args: SearchArgs) => Promise; setRating?: (args: SetRatingArgs) => Promise; shareItem?: (args: ShareItemArgs) => Promise; + updateInternetRadioStation: ( + args: UpdateInternetRadioStationArgs, + ) => Promise; updatePlaylist: (args: UpdatePlaylistArgs) => Promise; }; @@ -1351,10 +1417,16 @@ export type InternalControllerEndpoint = { body: { legacy?: boolean; password: string; username: string }, ) => Promise; createFavorite: (args: ReplaceApiClientProps) => Promise; + createInternetRadioStation: ( + args: ReplaceApiClientProps, + ) => Promise; createPlaylist: ( args: ReplaceApiClientProps, ) => Promise; deleteFavorite: (args: ReplaceApiClientProps) => Promise; + deleteInternetRadioStation: ( + args: ReplaceApiClientProps, + ) => Promise; deletePlaylist: ( args: ReplaceApiClientProps, ) => Promise; @@ -1377,6 +1449,9 @@ export type InternalControllerEndpoint = { getDownloadUrl: (args: ReplaceApiClientProps) => string; getFolder: (args: ReplaceApiClientProps) => Promise; getGenreList: (args: ReplaceApiClientProps) => Promise; + getInternetRadioStations: ( + args: ReplaceApiClientProps, + ) => Promise; getLyrics?: (args: ReplaceApiClientProps) => Promise; getMusicFolderList: ( args: ReplaceApiClientProps, @@ -1423,6 +1498,9 @@ export type InternalControllerEndpoint = { search: (args: ReplaceApiClientProps) => Promise; setRating?: (args: ReplaceApiClientProps) => Promise; shareItem?: (args: ReplaceApiClientProps) => Promise; + updateInternetRadioStation: ( + args: ReplaceApiClientProps, + ) => Promise; updatePlaylist: ( args: ReplaceApiClientProps, ) => Promise; diff --git a/src/shared/types/types.ts b/src/shared/types/types.ts index ba782bb8..ab664d7f 100644 --- a/src/shared/types/types.ts +++ b/src/shared/types/types.ts @@ -28,6 +28,7 @@ export enum ItemListKey { PLAYLIST = LibraryItem.PLAYLIST, PLAYLIST_SONG = LibraryItem.PLAYLIST_SONG, QUEUE_SONG = LibraryItem.QUEUE_SONG, + RADIO = 'radio', SIDE_QUEUE = 'sideQueue', SONG = LibraryItem.SONG, }