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