diff --git a/api/serializers/v2.py b/api/serializers/v2.py index a2087a213..ef907818d 100644 --- a/api/serializers/v2.py +++ b/api/serializers/v2.py @@ -85,6 +85,7 @@ class DeviceSettingsSerializerV2(Serializer): shuffle_playlist = BooleanField() use_24_hour_clock = BooleanField() debug_logging = BooleanField() + rotate_display = IntegerField() username = CharField() @@ -99,6 +100,7 @@ class UpdateDeviceSettingsSerializerV2(Serializer): shuffle_playlist = BooleanField(required=False) use_24_hour_clock = BooleanField(required=False) debug_logging = BooleanField(required=False) + rotate_display = IntegerField(required=False) username = CharField(required=False, allow_blank=True) password = CharField(required=False, allow_blank=True) password_2 = CharField(required=False, allow_blank=True) diff --git a/api/tests/test_v2_endpoints.py b/api/tests/test_v2_endpoints.py index 1680bb1e0..6496e357e 100644 --- a/api/tests/test_v2_endpoints.py +++ b/api/tests/test_v2_endpoints.py @@ -31,6 +31,7 @@ def test_get_device_settings(self, settings_mock): 'shuffle_playlist': False, 'use_24_hour_clock': True, 'debug_logging': False, + 'rotate_display': 0, 'user': '', }[key] @@ -246,6 +247,7 @@ def test_disable_basic_auth(self, publisher_mock, settings_mock): 'shuffle_playlist': False, 'use_24_hour_clock': True, 'debug_logging': False, + 'rotate_display': 0, }[key] settings_mock.__setitem__ = mock.MagicMock() settings_mock.auth_backends = { diff --git a/api/views/v2.py b/api/views/v2.py index 61dcd3c83..d8a98ac85 100644 --- a/api/views/v2.py +++ b/api/views/v2.py @@ -218,6 +218,7 @@ def get(self, request): 'shuffle_playlist': settings['shuffle_playlist'], 'use_24_hour_clock': settings['use_24_hour_clock'], 'debug_logging': settings['debug_logging'], + 'rotate_display': int(settings['rotate_display']), 'username': ( settings['user'] if settings['auth_backend'] == 'auth_basic' @@ -354,6 +355,8 @@ def patch(self, request): settings['use_24_hour_clock'] = data['use_24_hour_clock'] if 'debug_logging' in data: settings['debug_logging'] = data['debug_logging'] + if 'rotate_display' in data: + settings['rotate_display'] = data['rotate_display'] settings.save() publisher = ZmqPublisher.get_instance() diff --git a/pyproject.toml b/pyproject.toml index a565248bd..4434efeb1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,3 +21,4 @@ python-on-whales = '^0.79.0' [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + diff --git a/settings.py b/settings.py index b848ee6d4..809ba2a78 100644 --- a/settings.py +++ b/settings.py @@ -37,6 +37,7 @@ 'default_streaming_duration': '300', 'player_name': '', 'resolution': '1920x1080', + 'rotate_display': 0, 'show_splash': True, 'shuffle_playlist': False, 'verify_ssl': True, diff --git a/static/src/components/settings/index.tsx b/static/src/components/settings/index.tsx index 4f1e5a9b3..ad15a3476 100644 --- a/static/src/components/settings/index.tsx +++ b/static/src/components/settings/index.tsx @@ -17,6 +17,7 @@ import { PlayerName } from '@/components/settings/player-name' import { DefaultDurations } from '@/components/settings/default-durations' import { AudioOutput } from '@/components/settings/audio-output' import { DateFormat } from '@/components/settings/date-format' +import { RotateDisplay } from '@/components/settings/rotate-display' import { ToggleableSetting } from '@/components/settings/toggleable-setting' import { Update } from '@/components/settings/update' @@ -138,6 +139,11 @@ export const Settings = () => { handleInputChange={handleInputChange} /> + + diff --git a/static/src/components/settings/rotate-display.tsx b/static/src/components/settings/rotate-display.tsx new file mode 100644 index 000000000..010b92cc8 --- /dev/null +++ b/static/src/components/settings/rotate-display.tsx @@ -0,0 +1,28 @@ +import { RootState } from '@/types' + +export const RotateDisplay = ({ + settings, + handleInputChange, +}: { + settings: RootState['settings']['settings'] + handleInputChange: (e: React.ChangeEvent) => void +}) => { + return ( +
+ + +
+ ) +} diff --git a/static/src/store/settings/index.ts b/static/src/store/settings/index.ts index ee97c67e4..33935012d 100644 --- a/static/src/store/settings/index.ts +++ b/static/src/store/settings/index.ts @@ -35,6 +35,7 @@ export const fetchSettings = createAsyncThunk( shufflePlaylist: data.shuffle_playlist || false, use24HourClock: data.use_24_hour_clock || false, debugLogging: data.debug_logging || false, + rotateDisplay: data.rotate_display || 0, } } catch (error) { return rejectWithValue((error as Error).message) @@ -76,6 +77,7 @@ export const updateSettings = createAsyncThunk( shuffle_playlist: settings.shufflePlaylist, use_24_hour_clock: settings.use24HourClock, debug_logging: settings.debugLogging, + rotate_display: settings.rotateDisplay, }), }) @@ -176,6 +178,7 @@ const initialState = { shufflePlaylist: false, use24HourClock: false, debugLogging: false, + rotateDisplay: 0, }, deviceModel: '', prevAuthBackend: '', diff --git a/static/src/tests/settings.test.tsx b/static/src/tests/settings.test.tsx index 5313aceb9..fe54d66de 100644 --- a/static/src/tests/settings.test.tsx +++ b/static/src/tests/settings.test.tsx @@ -45,6 +45,7 @@ const createMockStore = (preloadedState: Partial = {}) => { shufflePlaylist: true, use24HourClock: false, debugLogging: true, + rotateDisplay: 0, }, deviceModel: 'Raspberry Pi 4', isLoading: false, @@ -180,6 +181,7 @@ describe('Settings Component', () => { shufflePlaylist: true, use24HourClock: false, debugLogging: true, + rotateDisplay: 0, }, deviceModel: 'Raspberry Pi 4', isLoading: true, diff --git a/static/src/tests/utils.ts b/static/src/tests/utils.ts index 621785daf..d1ac77ec7 100644 --- a/static/src/tests/utils.ts +++ b/static/src/tests/utils.ts @@ -154,6 +154,7 @@ export function getInitialState(): RootState { shufflePlaylist: false, use24HourClock: false, debugLogging: false, + rotateDisplay: 0, }, deviceModel: '', prevAuthBackend: '', diff --git a/static/src/types.ts b/static/src/types.ts index c4e74c016..0566728c5 100644 --- a/static/src/types.ts +++ b/static/src/types.ts @@ -117,6 +117,7 @@ export interface RootState { shufflePlaylist: boolean use24HourClock: boolean debugLogging: boolean + rotateDisplay: number } deviceModel: string prevAuthBackend: string @@ -174,6 +175,7 @@ export interface SettingsData { shufflePlaylist: boolean use24HourClock: boolean debugLogging: boolean + rotateDisplay: number } export interface SystemOperationParams { diff --git a/viewer/media_player.py b/viewer/media_player.py index fba2db9e0..197f9353a 100644 --- a/viewer/media_player.py +++ b/viewer/media_player.py @@ -36,9 +36,34 @@ def __init__(self): def set_asset(self, uri, duration): self.uri = uri + def __get_rotation_filter(self): + rotation = settings.get('rotate_display', 0) + rotation_angles = { + 0: '', + 90: 'transpose=1', + 180: 'hflip,vflip', + 270: 'transpose=2', + } + return rotation_angles.get(rotation, '') + def play(self): + rotation_filter = self.__get_rotation_filter() + filters = [] + if rotation_filter: + filters.append(rotation_filter) + + vf_arg = ','.join(filters) if filters else None + + cmd = [ + 'ffplay', + '-autoexit', + ] + if vf_arg: + cmd.extend(['-vf', vf_arg]) + cmd.append(self.uri) + self.process = subprocess.Popen( - ['ffplay', '-autoexit', self.uri], + cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, ) @@ -82,8 +107,18 @@ def get_alsa_audio_device(self): return 'default:CARD=HID' def __get_options(self): + rotation = settings.get('rotate_display', 0) + rotation_angles = { + 0: 0, + 1: 90, + 2: 180, + 3: 270, + } + angle = rotation_angles.get(rotation, 0) + return [ f'--alsa-audio-device={self.get_alsa_audio_device()}', + f'--video-filter=rotate{{angle={angle}}}', ] def set_asset(self, uri, duration):