diff --git a/src/components/App.tsx b/src/components/App.tsx index 80085f0..0129ec2 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -30,9 +30,12 @@ const DarkTheme = { } }; +// Track whether the player has already been setup, so that we don't +// accidentally do it twice. +let hasSetupPlayer = false; + export default function App(): JSX.Element { const colorScheme = useColorScheme(); - // const colorScheme = 'dark'; const theme = themes[colorScheme || 'light']; useEffect(() => { @@ -47,9 +50,14 @@ export default function App(): JSX.Element { Capability.Stop, Capability.SeekTo, ], + progressUpdateEventInterval: 5, }); } - setupTrackPlayer(); + + if (!hasSetupPlayer) { + setupTrackPlayer(); + hasSetupPlayer = true; + } }, []); return ( diff --git a/src/localisation/lang/en/locale.json b/src/localisation/lang/en/locale.json index a12a891..5422c5f 100644 --- a/src/localisation/lang/en/locale.json +++ b/src/localisation/lang/en/locale.json @@ -61,5 +61,7 @@ "local-playback": "Local playback", "streaming": "Streaming", "total-duration": "Total duration", - "similar-albums": "Similar albums" + "similar-albums": "Similar albums", + "playback-reporting": "Playback Reporting", + "playback-reporting-description": "With Playback Reporting, all your playback events are relayed back to Jellyfin. This allows you to track your most listened songs, particularly with Jellyfin plugins such as ListenBrainz." } \ No newline at end of file diff --git a/src/localisation/types.ts b/src/localisation/types.ts index dfae5e9..517e2b2 100644 --- a/src/localisation/types.ts +++ b/src/localisation/types.ts @@ -59,4 +59,6 @@ export type LocaleKeys = 'play-next' | 'local-playback' | 'streaming' | 'total-duration' -| 'similar-albums' \ No newline at end of file +| 'similar-albums' +| 'playback-reporting' +| 'playback-reporting-description' \ No newline at end of file diff --git a/src/screens/Settings/components/PlaybackReporting.tsx b/src/screens/Settings/components/PlaybackReporting.tsx new file mode 100644 index 0000000..7143e2f --- /dev/null +++ b/src/screens/Settings/components/PlaybackReporting.tsx @@ -0,0 +1,44 @@ +import { Paragraph, Text } from 'components/Typography'; +import React, { useCallback } from 'react'; +import { Switch } from 'react-native-gesture-handler'; +import styled from 'styled-components/native'; +import { t } from '@localisation'; +import { SafeScrollView } from 'components/SafeNavigatorView'; +import { useAppDispatch, useTypedSelector } from 'store'; +import { setEnablePlaybackReporting } from 'store/settings/actions'; + +const Container = styled.View` + padding: 24px; +`; + +const SwitchContainer = styled.View` + flex-direction: row; + justify-content: space-between; + align-items: center; + margin: 16px 0; +`; + +const Label = styled(Text)` + font-size: 16px; +`; + +export default function PlaybackReporting() { + const isEnabled = useTypedSelector((state) => state.settings.enablePlaybackReporting); + const dispatch = useAppDispatch(); + + const toggleSwitch = useCallback(() => { + dispatch(setEnablePlaybackReporting(!isEnabled)); + }, [isEnabled, dispatch]); + + return ( + + + {t('playback-reporting-description')} + + + + + + + ); +} \ No newline at end of file diff --git a/src/screens/Settings/index.tsx b/src/screens/Settings/index.tsx index 11e6062..55a994c 100644 --- a/src/screens/Settings/index.tsx +++ b/src/screens/Settings/index.tsx @@ -11,18 +11,21 @@ import { THEME_COLOR } from 'CONSTANTS'; import Sentry from './components/Sentry'; import { SettingsNavigationProp } from './types'; import { SafeScrollView } from 'components/SafeNavigatorView'; +import PlaybackReporting from './components/PlaybackReporting'; export function SettingsList() { const navigation = useNavigation(); const handleLibraryClick = useCallback(() => { navigation.navigate('Library'); }, [navigation]); const handleCacheClick = useCallback(() => { navigation.navigate('Cache'); }, [navigation]); const handleSentryClick = useCallback(() => { navigation.navigate('Sentry'); }, [navigation]); + const handlePlaybackReportingClick = useCallback(() => { navigation.navigate('Playback Reporting'); }, [navigation]); return ( {t('jellyfin-library')} {t('setting-cache')} {t('error-reporting')} + {t('playback-reporting')} ); } @@ -43,6 +46,7 @@ export default function Settings() { + ); } \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index 78d4e8c..f49658f 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -33,7 +33,17 @@ const persistConfig: PersistConfig> = { queued: [] } }; - } + }, + // @ts-expect-error migrations are poorly typed + 3: (state: AppState) => { + return { + ...state, + settings: { + ...state.settings, + enablePlaybackReporting: true, + } + }; + }, }) }; diff --git a/src/store/settings/actions.ts b/src/store/settings/actions.ts index 63c69a8..3f217dd 100644 --- a/src/store/settings/actions.ts +++ b/src/store/settings/actions.ts @@ -3,4 +3,5 @@ import { createAction } from '@reduxjs/toolkit'; export const setJellyfinCredentials = createAction<{ access_token: string, user_id: string, uri: string, device_id: string; }>('SET_JELLYFIN_CREDENTIALS'); export const setBitrate = createAction('SET_BITRATE'); export const setOnboardingStatus = createAction('SET_ONBOARDING_STATUS'); -export const setReceivedErrorReportingAlert = createAction('SET_RECEIVED_ERROR_REPORTING_ALERT'); \ No newline at end of file +export const setReceivedErrorReportingAlert = createAction('SET_RECEIVED_ERROR_REPORTING_ALERT'); +export const setEnablePlaybackReporting = createAction('SET_ENABLE_PLAYBACK_REPORTING'); \ No newline at end of file diff --git a/src/store/settings/index.ts b/src/store/settings/index.ts index 00b5b25..216efb2 100644 --- a/src/store/settings/index.ts +++ b/src/store/settings/index.ts @@ -1,5 +1,5 @@ import { createReducer } from '@reduxjs/toolkit'; -import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus } from './actions'; +import { setReceivedErrorReportingAlert, setBitrate, setJellyfinCredentials, setOnboardingStatus, setEnablePlaybackReporting } from './actions'; interface State { jellyfin?: { @@ -11,12 +11,14 @@ interface State { bitrate: number; isOnboardingComplete: boolean; hasReceivedErrorReportingAlert: boolean; + enablePlaybackReporting: boolean; } const initialState: State = { bitrate: 140000000, isOnboardingComplete: false, hasReceivedErrorReportingAlert: false, + enablePlaybackReporting: true, }; const settings = createReducer(initialState, builder => { @@ -36,6 +38,10 @@ const settings = createReducer(initialState, builder => { ...state, hasReceivedErrorReportingAlert: true, })); + builder.addCase(setEnablePlaybackReporting, (state, action) => ({ + ...state, + enablePlaybackReporting: action.payload, + })); }); export default settings; \ No newline at end of file diff --git a/src/utility/JellyfinApi.ts b/src/utility/JellyfinApi.ts index 9d7ec13..af85d9d 100644 --- a/src/utility/JellyfinApi.ts +++ b/src/utility/JellyfinApi.ts @@ -1,4 +1,4 @@ -import { Track } from 'react-native-track-player'; +import TrackPlayer, { RepeatMode, State, Track } from 'react-native-track-player'; import { AppState, useTypedSelector } from 'store'; import { Album, AlbumTrack, SimilarAlbum } from 'store/music/types'; @@ -241,3 +241,58 @@ export async function retrievePlaylistTracks(ItemId: string, credentials: Creden return playlists.Items; } + +/** + * This maps the react-native-track-player RepeatMode to a RepeatMode that is + * expected by Jellyfin when reporting playback events. + */ +const RepeatModeMap: Record = { + [RepeatMode.Off]: 'RepeatNone', + [RepeatMode.Track]: 'RepeatOne', + [RepeatMode.Queue]: 'RepeatAll', +}; + +/** + * This will generate the payload that is required for playback events and send + * it to the supplied path. + */ +export async function sendPlaybackEvent(path: string, credentials: Credentials) { + // Extract all data from react-native-track-player + const [ + track, position, repeatMode, volume, queue, state, + ] = await Promise.all([ + TrackPlayer.getCurrentTrack(), + TrackPlayer.getPosition(), + TrackPlayer.getRepeatMode(), + TrackPlayer.getVolume(), + TrackPlayer.getQueue(), + TrackPlayer.getState(), + ]); + + // Generate a payload from the gathered data + const payload = { + VolumeLevel: volume * 100, + IsMuted: false, + IsPaused: state === State.Paused, + RepeatMode: RepeatModeMap[repeatMode], + ShuffleMode: 'Sorted', + PositionTicks: position * 1_000_000, + PlaybackRate: 1, + PlayMethod: 'transcode', + MediaSourceId: track ? queue[track].backendId : null, + ItemId: track ? queue[track].backendId : null, + CanSeek: true, + PlaybackStartTimeTicks: null, + }; + + // Generate a config from the credentials and dispatch the request + const config = generateConfig(credentials); + await fetch(`${credentials?.uri}${path}`, { + headers: { + ...config.headers, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload), + // Swallow and errors from the request + }).catch(() => {}); +} \ No newline at end of file diff --git a/src/utility/PlaybackService.ts b/src/utility/PlaybackService.ts index 625fa6f..7b77076 100644 --- a/src/utility/PlaybackService.ts +++ b/src/utility/PlaybackService.ts @@ -7,7 +7,9 @@ * such as processing media buttons or analytics */ -import TrackPlayer, { Event } from 'react-native-track-player'; +import TrackPlayer, { Event, State } from 'react-native-track-player'; +import store from 'store'; +import { sendPlaybackEvent } from './JellyfinApi'; export default async function() { TrackPlayer.addEventListener(Event.RemotePlay, () => { @@ -33,5 +35,38 @@ export default async function() { TrackPlayer.addEventListener(Event.RemoteSeek, (event) => { TrackPlayer.seekTo(event.position); }); + + TrackPlayer.addEventListener(Event.PlaybackTrackChanged, () => { + // Retrieve the current settings from the Redux store + const settings = store.getState().settings; + + // GUARD: Only report playback when the settings is enabled + if (settings.enablePlaybackReporting) { + sendPlaybackEvent('/Sessions/Playing', settings.jellyfin); + } + }); + + TrackPlayer.addEventListener(Event.PlaybackProgressUpdated, () => { + // Retrieve the current settings from the Redux store + const settings = store.getState().settings; + + // GUARD: Only report playback when the settings is enabled + if (settings.enablePlaybackReporting) { + sendPlaybackEvent('/Sessions/Playing/Progress', settings.jellyfin); + } + }); + + TrackPlayer.addEventListener(Event.PlaybackState, (event) => { + // GUARD: Only respond to paused and stopped events + if (event.state === State.Paused || event.state === State.Stopped) { + // Retrieve the current settings from the Redux store + const settings = store.getState().settings; + + // GUARD: Only report playback when the settings is enabled + if (settings.enablePlaybackReporting) { + sendPlaybackEvent('/Sessions/Playing/Stopped', settings.jellyfin); + } + } + }); } \ No newline at end of file