Add MPC-BE remote control and resume support.
This commit is contained in:
+621
@@ -0,0 +1,621 @@
|
||||
# MPC-BE Web Interface API
|
||||
|
||||
This document describes the MPC-BE web interface exposed by the local player instance running on `http://127.0.0.1:13579`.
|
||||
|
||||
This version is based on the actual MPC-BE source from GitHub, not just live UI inspection.
|
||||
|
||||
## Authoritative upstream sources
|
||||
|
||||
The command list and web behavior below come from these MPC-BE source files:
|
||||
|
||||
- `src/apps/mplayerc/resource.h`
|
||||
- authoritative numeric command IDs
|
||||
- `src/apps/mplayerc/WebClient.cpp`
|
||||
- `command.html` behavior and special web-only commands
|
||||
- `src/apps/mplayerc/WebServer.cpp`
|
||||
- endpoint table and built-in asset deployment
|
||||
- `src/apps/mplayerc/WebClient.h`
|
||||
- request handler declarations
|
||||
|
||||
Upstream repository:
|
||||
|
||||
- https://github.com/Aleksoid1978/MPC-BE
|
||||
|
||||
Relevant source paths:
|
||||
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/resource.h
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/WebClient.cpp
|
||||
- https://github.com/Aleksoid1978/MPC-BE/blob/master/src/apps/mplayerc/WebServer.cpp
|
||||
|
||||
## Base URL
|
||||
|
||||
- `http://127.0.0.1:13579`
|
||||
|
||||
## Endpoints
|
||||
|
||||
From `WebServer.cpp`, the built-in internal pages are:
|
||||
|
||||
- `GET /`
|
||||
- `GET /index.html`
|
||||
- `GET /info.html`
|
||||
- `GET /browser.html`
|
||||
- `GET /controls.html`
|
||||
- `GET /command.html`
|
||||
- `POST /command.html`
|
||||
- `GET /status.html`
|
||||
- `GET /player.html`
|
||||
- `GET /variables.html`
|
||||
- `GET /snapshot.jpg`
|
||||
- `GET /404.html`
|
||||
|
||||
Built-in downloadable assets exposed by the web server include:
|
||||
|
||||
- `GET /default.css`
|
||||
- `GET /favicon.png`
|
||||
- `GET /logo.png`
|
||||
- `GET /seekbarleft.png`
|
||||
- `GET /seekbarmid.png`
|
||||
- `GET /seekbarright.png`
|
||||
- `GET /seekbargrip.png`
|
||||
- `GET /controlbuttonplay.png`
|
||||
- `GET /controlbuttonpause.png`
|
||||
- `GET /controlbuttonstop.png`
|
||||
- `GET /controlbuttonskipback.png`
|
||||
- `GET /controlbuttondecrate.png`
|
||||
- `GET /controlbuttonincrate.png`
|
||||
- `GET /controlbuttonskipforward.png`
|
||||
- `GET /controlbuttonstep.png`
|
||||
- `GET /controlvolumeon.png`
|
||||
- `GET /controlvolumeoff.png`
|
||||
- `GET /controlvolumebar.png`
|
||||
- `GET /controlvolumegrip.png`
|
||||
|
||||
The web server can also expose static files and CGI handlers from the configured web root.
|
||||
|
||||
## `command.html` behavior
|
||||
|
||||
From `WebClient.cpp`, `command.html` reads `wm_command` from the request and behaves as follows:
|
||||
|
||||
- If `wm_command == CMD_SETPOS`
|
||||
- accepts either `position=HH:MM:SS[.ms]`
|
||||
- or `percent=<number>`
|
||||
- If `wm_command == CMD_SETVOLUME`
|
||||
- accepts `volume=<0-100>`
|
||||
- If `wm_command == ID_FILE_EXIT`
|
||||
- posts the exit command asynchronously
|
||||
- For any other positive `wm_command`
|
||||
- forwards it to the player as `WM_COMMAND`
|
||||
|
||||
This is the key point: the web API is not limited to a tiny hardcoded list. For any positive command ID defined in `resource.h`, the web server forwards it into the player.
|
||||
|
||||
## Special web-only command IDs
|
||||
|
||||
These are handled specially by `WebClient.cpp` and are not normal `resource.h` command IDs:
|
||||
|
||||
| `wm_command` | Meaning | Parameters |
|
||||
| --- | --- | --- |
|
||||
| `-1` | Absolute seek | `position=HH:MM:SS[.ms]` or `percent=<0-100>` |
|
||||
| `-2` | Absolute volume set | `volume=<0-100>` |
|
||||
|
||||
Examples:
|
||||
|
||||
```text
|
||||
/command.html?wm_command=-1&position=01:07:42
|
||||
/command.html?wm_command=-1&percent=50
|
||||
/command.html?wm_command=-2&volume=80
|
||||
```
|
||||
|
||||
## Variables exposed by `/variables.html`
|
||||
|
||||
From `WebClient.cpp`, `OnVariables()` emits the following fields into the HTML page:
|
||||
|
||||
- `file`
|
||||
- `filepatharg`
|
||||
- `filepath`
|
||||
- `filedirarg`
|
||||
- `filedir`
|
||||
- `state`
|
||||
- `statestring`
|
||||
- `position`
|
||||
- `positionstring`
|
||||
- `duration`
|
||||
- `durationstring`
|
||||
- `volumelevel`
|
||||
- `muted`
|
||||
- `playbackrate`
|
||||
- `size`
|
||||
- `reloadtime`
|
||||
- `hdr`
|
||||
- `version`
|
||||
|
||||
Observed meanings from source:
|
||||
|
||||
- `state`: numeric `OAFilterState`
|
||||
- `statestring`: localized text such as playing, paused, stopped
|
||||
- `position`: current playback position in milliseconds
|
||||
- `positionstring`: current playback position formatted as `HH:MM:SS`
|
||||
- `duration`: total duration in milliseconds
|
||||
- `durationstring`: total duration formatted as `HH:MM:SS`
|
||||
- `volumelevel`: toolbar volume control position, usually `0-100`
|
||||
- `muted`: `1` when muted, `0` otherwise
|
||||
- `playbackrate`: currently emitted as `1` in this source path
|
||||
- `size`: formatted file size string
|
||||
- `reloadtime`: currently emitted as `0`
|
||||
- `hdr`: `SDR`, `HDR`, or `HDR(HLG)` depending on media/render graph inspection
|
||||
- `version`: full MPC-BE version string
|
||||
|
||||
Example shape:
|
||||
|
||||
```html
|
||||
<p id="position">4062466</p>
|
||||
<p id="positionstring">01:07:42</p>
|
||||
<p id="duration">8060032</p>
|
||||
<p id="durationstring">02:14:20</p>
|
||||
```
|
||||
|
||||
## Browser endpoint behavior
|
||||
|
||||
From `WebClient.cpp`, `browser.html` supports:
|
||||
|
||||
- `path=<filesystem path>`
|
||||
- optional `focus=no`
|
||||
|
||||
When `path` points to a file, the browser page can send a `WM_COPYDATA` message to open that file in MPC-BE.
|
||||
|
||||
## Actual command listing from `resource.h`
|
||||
|
||||
These are the actual command IDs defined by MPC-BE in the `commands` block of `src/apps/mplayerc/resource.h`.
|
||||
|
||||
Because `WebClient.cpp` forwards any positive `wm_command` to `WM_COMMAND`, these are the real actionable web command IDs.
|
||||
|
||||
### File commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 800 | `ID_FILE_OPENFILEURL` |
|
||||
| 801 | `ID_FILE_OPENDVD` |
|
||||
| 802 | `ID_FILE_OPENDEVICE` |
|
||||
| 803 | `ID_FILE_CLOSEMEDIA` |
|
||||
| 804 | `ID_FILE_CLOSEPLAYLIST` |
|
||||
| 805 | `ID_FILE_SAVE_COPY` |
|
||||
| 806 | `ID_FILE_SAVE_IMAGE` |
|
||||
| 807 | `ID_FILE_AUTOSAVE_IMAGE` |
|
||||
| 808 | `ID_FILE_SAVE_THUMBNAILS` |
|
||||
| 809 | `ID_FILE_LOAD_SUBTITLE` |
|
||||
| 810 | `ID_FILE_SAVE_SUBTITLE` |
|
||||
| 812 | `ID_FILE_ISDB_DOWNLOAD` |
|
||||
| 813 | `ID_FILE_ISDB_SEARCH` |
|
||||
| 814 | `ID_FILE_PROPERTIES` |
|
||||
| 816 | `ID_FILE_EXIT` |
|
||||
| 969 | `ID_FILE_OPENFILE` |
|
||||
| 976 | `ID_FILE_REOPEN` |
|
||||
| 996 | `ID_FILE_AUTOSAVE_DISPLAY` |
|
||||
| 1016 | `ID_FILE_OPENDIRECTORY` |
|
||||
| 1035 | `ID_FILE_LOAD_AUDIO` |
|
||||
| 1090 | `ID_FILE_OPENISO` |
|
||||
|
||||
### View and window commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 815 | `ID_VIEW_OPTIONS` |
|
||||
| 817 | `ID_VIEW_CAPTIONMENU` |
|
||||
| 818 | `ID_VIEW_SEEKER` |
|
||||
| 819 | `ID_VIEW_CONTROLS` |
|
||||
| 820 | `ID_VIEW_INFORMATION` |
|
||||
| 821 | `ID_VIEW_STATISTICS` |
|
||||
| 822 | `ID_VIEW_STATUS` |
|
||||
| 823 | `ID_VIEW_SUBRESYNC` |
|
||||
| 824 | `ID_VIEW_PLAYLIST` |
|
||||
| 825 | `ID_VIEW_CAPTURE` |
|
||||
| 826 | `ID_VIEW_SHADEREDITOR` |
|
||||
| 827 | `ID_VIEW_PRESETS_MINIMAL` |
|
||||
| 828 | `ID_VIEW_PRESETS_COMPACT` |
|
||||
| 829 | `ID_VIEW_PRESETS_NORMAL` |
|
||||
| 830 | `ID_VIEW_FULLSCREEN` |
|
||||
| 831 | `ID_VIEW_FULLSCREEN_2` |
|
||||
| 832 | `ID_VIEW_ZOOM_50` |
|
||||
| 833 | `ID_VIEW_ZOOM_100` |
|
||||
| 834 | `ID_VIEW_ZOOM_200` |
|
||||
| 835 | `ID_VIEW_VF_HALF` |
|
||||
| 836 | `ID_VIEW_VF_NORMAL` |
|
||||
| 837 | `ID_VIEW_VF_DOUBLE` |
|
||||
| 838 | `ID_VIEW_VF_STRETCH` |
|
||||
| 839 | `ID_VIEW_VF_FROMINSIDE` |
|
||||
| 840 | `ID_VIEW_VF_FROMOUTSIDE` |
|
||||
| 841 | `ID_VIEW_VF_ZOOM1` |
|
||||
| 842 | `ID_VIEW_VF_ZOOM2` |
|
||||
| 843 | `ID_VIEW_VF_SWITCHZOOM` |
|
||||
| 844 | `ID_VIEW_VF_KEEPASPECTRATIO` |
|
||||
| 845 | `ID_VIEW_VF_COMPMONDESKARDIFF` |
|
||||
| 859 | `ID_ASPECTRATIO_NEXT` |
|
||||
| 861 | `ID_VIEW_RESET` |
|
||||
| 862 | `ID_VIEW_INCSIZE` |
|
||||
| 863 | `ID_VIEW_DECSIZE` |
|
||||
| 864 | `ID_VIEW_INCWIDTH` |
|
||||
| 865 | `ID_VIEW_DECWIDTH` |
|
||||
| 866 | `ID_VIEW_INCHEIGHT` |
|
||||
| 867 | `ID_VIEW_DECHEIGHT` |
|
||||
| 968 | `ID_VIEW_ZOOM_AUTOFIT` |
|
||||
| 1009 | `ID_VIEW_NAVIGATION` |
|
||||
| 1023 | `ID_D3DFULLSCREEN_TOGGLE` |
|
||||
| 1038 | `ID_WINDOW_TO_PRIMARYSCREEN` |
|
||||
| 1040 | `ID_VIEW_RESETSTATS` |
|
||||
| 1041 | `ID_VIEW_TEARING_TEST` |
|
||||
| 1042 | `ID_VIEW_DISPLAYSTATS` |
|
||||
| 1043 | `ID_VIEW_REMAINING_TIME` |
|
||||
| 1044 | `ID_VIEW_EVROUTPUTRANGE_0_255` |
|
||||
| 1045 | `ID_VIEW_EVROUTPUTRANGE_16_235` |
|
||||
| 1046 | `ID_VIEW_EXCLUSIVE_FULLSCREEN` |
|
||||
| 1053 | `ID_VIEW_ENABLEFRAMETIMECORRECTION` |
|
||||
| 1066 | `ID_VIEW_VSYNC` |
|
||||
| 1067 | `ID_VIEW_VSYNCINTERNAL` |
|
||||
| 1070 | `ID_VIEW_VSYNCOFFSET_DECREASE` |
|
||||
| 1071 | `ID_VIEW_VSYNCOFFSET_INCREASE` |
|
||||
| 1075 | `ID_VIEW_RESET_DEFAULT` |
|
||||
|
||||
### Aspect ratio presets
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 850 | `ID_ASPECTRATIO_SOURCE` |
|
||||
| 851 | `ID_ASPECTRATIO_4_3` |
|
||||
| 852 | `ID_ASPECTRATIO_5_4` |
|
||||
| 853 | `ID_ASPECTRATIO_16_9` |
|
||||
| 854 | `ID_ASPECTRATIO_235_100` |
|
||||
| 855 | `ID_ASPECTRATIO_185_100` |
|
||||
|
||||
### Pan and scan commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 868 | `ID_PANSCAN_MOVELEFT` |
|
||||
| 869 | `ID_PANSCAN_MOVERIGHT` |
|
||||
| 870 | `ID_PANSCAN_MOVEUP` |
|
||||
| 871 | `ID_PANSCAN_MOVEDOWN` |
|
||||
| 872 | `ID_PANSCAN_MOVEUPLEFT` |
|
||||
| 873 | `ID_PANSCAN_MOVEUPRIGHT` |
|
||||
| 874 | `ID_PANSCAN_MOVEDOWNLEFT` |
|
||||
| 875 | `ID_PANSCAN_MOVEDOWNRIGHT` |
|
||||
| 876 | `ID_PANSCAN_CENTER` |
|
||||
| 880 | `ID_PANSCAN_FLIP` |
|
||||
| 881 | `ID_PANSCAN_ROTATE_CCW` |
|
||||
| 882 | `ID_PANSCAN_ROTATE_CW` |
|
||||
|
||||
### On-top modes
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 883 | `ID_ONTOP_NEVER` |
|
||||
| 884 | `ID_ONTOP_ALWAYS` |
|
||||
| 885 | `ID_ONTOP_WHILEPLAYING` |
|
||||
| 886 | `ID_ONTOP_WHILEPLAYINGVIDEO` |
|
||||
|
||||
### Playback commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 887 | `ID_PLAY_PLAY` |
|
||||
| 888 | `ID_PLAY_PAUSE` |
|
||||
| 889 | `ID_PLAY_PLAYPAUSE` |
|
||||
| 890 | `ID_PLAY_STOP` |
|
||||
| 891 | `ID_PLAY_FRAMESTEP` |
|
||||
| 892 | `ID_PLAY_FRAMESTEP_BACK` |
|
||||
| 893 | `ID_PLAY_GOTO` |
|
||||
| 894 | `ID_PLAY_DECRATE` |
|
||||
| 895 | `ID_PLAY_INCRATE` |
|
||||
| 896 | `ID_PLAY_RESETRATE` |
|
||||
| 897 | `ID_PLAY_SEEKKEYBACKWARD` |
|
||||
| 898 | `ID_PLAY_SEEKKEYFORWARD` |
|
||||
| 899 | `ID_PLAY_SEEKBACKWARDSMALL` |
|
||||
| 900 | `ID_PLAY_SEEKFORWARDSMALL` |
|
||||
| 901 | `ID_PLAY_SEEKBACKWARDMED` |
|
||||
| 902 | `ID_PLAY_SEEKFORWARDMED` |
|
||||
| 903 | `ID_PLAY_SEEKBACKWARDLARGE` |
|
||||
| 904 | `ID_PLAY_SEEKFORWARDLARGE` |
|
||||
| 905 | `ID_PLAY_AUDIODELAY_PLUS` |
|
||||
| 906 | `ID_PLAY_AUDIODELAY_MINUS` |
|
||||
| 995 | `ID_PLAY_AUDIODELAY_ONOFF` |
|
||||
| 1085 | `ID_PLAY_SEEKBEGIN` |
|
||||
| 1201 | `ID_PLAY_REPEAT_AB` |
|
||||
| 1202 | `ID_PLAY_REPEAT_AB_MARK_A` |
|
||||
| 1203 | `ID_PLAY_REPEAT_AB_MARK_B` |
|
||||
|
||||
### Volume commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 907 | `ID_VOLUME_UP` |
|
||||
| 908 | `ID_VOLUME_DOWN` |
|
||||
| 909 | `ID_VOLUME_MUTE` |
|
||||
| 910 | `ID_VOLUME_MUTE_OFF` |
|
||||
| 911 | `ID_VOLUME_MUTE_DISABLED` |
|
||||
| 970 | `ID_VOLUME_GAIN_INC` |
|
||||
| 971 | `ID_VOLUME_GAIN_DEC` |
|
||||
| 972 | `ID_VOLUME_GAIN_OFF` |
|
||||
| 973 | `ID_VOLUME_GAIN_MAX` |
|
||||
|
||||
### After playback commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 912 | `ID_AFTERPLAYBACK_CLOSE` |
|
||||
| 913 | `ID_AFTERPLAYBACK_STANDBY` |
|
||||
| 914 | `ID_AFTERPLAYBACK_HIBERNATE` |
|
||||
| 915 | `ID_AFTERPLAYBACK_SHUTDOWN` |
|
||||
| 916 | `ID_AFTERPLAYBACK_LOGOFF` |
|
||||
| 917 | `ID_AFTERPLAYBACK_LOCK` |
|
||||
| 947 | `ID_AFTERPLAYBACK_NEXT` |
|
||||
| 948 | `ID_AFTERPLAYBACK_DONOTHING` |
|
||||
| 1029 | `ID_AFTERPLAYBACK_ONCE` |
|
||||
| 1030 | `ID_AFTERPLAYBACK_EVERYTIME` |
|
||||
| 1077 | `ID_AFTERPLAYBACK_EXIT` |
|
||||
| 1078 | `ID_AFTERPLAYBACK_CLOSE_FILE` |
|
||||
| 1079 | `ID_AFTERPLAYBACK_NEXT_LOOPED` |
|
||||
| 1080 | `ID_AFTERPLAYBACK_CLOSE_FILE_AND_MINIMIZE` |
|
||||
| 1081 | `ID_AFTERPLAYBACK_EVERYTIMEDONOTHING` |
|
||||
|
||||
### Navigation commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 919 | `ID_NAVIGATE_SKIPBACKFILE` |
|
||||
| 920 | `ID_NAVIGATE_SKIPFORWARDFILE` |
|
||||
| 921 | `ID_NAVIGATE_SKIPBACK` |
|
||||
| 922 | `ID_NAVIGATE_SKIPFORWARD` |
|
||||
| 923 | `ID_NAVIGATE_TITLEMENU` |
|
||||
| 924 | `ID_NAVIGATE_ROOTMENU` |
|
||||
| 925 | `ID_NAVIGATE_SUBPICTUREMENU` |
|
||||
| 926 | `ID_NAVIGATE_AUDIOMENU` |
|
||||
| 927 | `ID_NAVIGATE_ANGLEMENU` |
|
||||
| 928 | `ID_NAVIGATE_CHAPTERMENU` |
|
||||
| 929 | `ID_NAVIGATE_MENU_LEFT` |
|
||||
| 930 | `ID_NAVIGATE_MENU_RIGHT` |
|
||||
| 931 | `ID_NAVIGATE_MENU_UP` |
|
||||
| 932 | `ID_NAVIGATE_MENU_DOWN` |
|
||||
| 933 | `ID_NAVIGATE_MENU_ACTIVATE` |
|
||||
| 934 | `ID_NAVIGATE_MENU_BACK` |
|
||||
| 935 | `ID_NAVIGATE_MENU_LEAVE` |
|
||||
| 974 | `ID_NAVIGATE_TUNERSCAN` |
|
||||
| 1033 | `ID_NAVIGATE_SUBTITLES` |
|
||||
| 1034 | `ID_NAVIGATE_AUDIO` |
|
||||
|
||||
### Menu, favorites, and help commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 936 | `ID_MENU_FAVORITES` |
|
||||
| 937 | `ID_FAVORITES_ORGANIZE` |
|
||||
| 938 | `ID_FAVORITES_ADD` |
|
||||
| 939 | `ID_HELP_HOMEPAGE` |
|
||||
| 940 | `ID_HELP_DONATE` |
|
||||
| 941 | `ID_HELP_SHOWCOMMANDLINESWITCHES` |
|
||||
| 942 | `ID_HELP_TOOLBARIMAGES` |
|
||||
| 943 | `ID_HELP_ABOUT` |
|
||||
| 944 | `ID_BOSS` |
|
||||
| 949 | `ID_MENU_PLAYER_LONG` |
|
||||
| 950 | `ID_MENU_PLAYER_SHORT` |
|
||||
| 951 | `ID_MENU_FILTERS` |
|
||||
| 975 | `ID_FAVORITES_QUICKADD` |
|
||||
| 1000 | `ID_MENU_AUDIOLANG` |
|
||||
| 1001 | `ID_MENU_SUBTITLELANG` |
|
||||
| 1002 | `ID_MENU_JUMPTO` |
|
||||
| 1003 | `ID_MENU_AFTERPLAYBACK` |
|
||||
| 1006 | `ID_MENU_RECENT_FILES` |
|
||||
| 1007 | `ID_START` |
|
||||
| 1008 | `ID_SAVE` |
|
||||
| 1018 | `ID_SHOW_HISTORY` |
|
||||
| 1019 | `ID_RECENT_FILES_CLEAR` |
|
||||
| 1032 | `ID_HELP_CHECKFORUPDATE` |
|
||||
|
||||
### Stream and subtitle/audio switching commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 952 | `ID_STREAM_AUDIO_NEXT` |
|
||||
| 953 | `ID_STREAM_AUDIO_PREV` |
|
||||
| 954 | `ID_STREAM_SUB_NEXT` |
|
||||
| 955 | `ID_STREAM_SUB_PREV` |
|
||||
| 956 | `ID_STREAM_SUB_ONOFF` |
|
||||
| 961 | `ID_STREAM_VIDEO_NEXT` |
|
||||
| 962 | `ID_STREAM_VIDEO_PREV` |
|
||||
| 1150 | `ID_AUDIO_CENTER_INC` |
|
||||
| 1151 | `ID_AUDIO_CENTER_DEC` |
|
||||
| 1160 | `ID_AUDIO_OPTIONS` |
|
||||
| 1170 | `ID_SUBTITLES_OPTIONS` |
|
||||
| 1171 | `ID_SUBTITLES_ENABLE` |
|
||||
| 1172 | `ID_SUBTITLES_STYLES` |
|
||||
| 1173 | `ID_SUBTITLES_RELOAD` |
|
||||
| 1175 | `ID_SUBTITLES_DEFSTYLE` |
|
||||
| 1176 | `ID_SUBTITLES_FORCEDONLY` |
|
||||
| 1177 | `ID_SUBTITLES_STEREO_DONTUSE` |
|
||||
| 1178 | `ID_SUBTITLES_STEREO_SIDEBYSIDE` |
|
||||
| 1179 | `ID_SUBTITLES_STEREO_TOPBOTTOM` |
|
||||
|
||||
### Misc playback and OSD commands
|
||||
|
||||
| ID | Symbol |
|
||||
| --- | --- |
|
||||
| 967 | `ID_REPEAT_FOREVER` |
|
||||
| 984 | `ID_COLOR_BRIGHTNESS_INC` |
|
||||
| 985 | `ID_COLOR_BRIGHTNESS_DEC` |
|
||||
| 986 | `ID_COLOR_CONTRAST_INC` |
|
||||
| 987 | `ID_COLOR_CONTRAST_DEC` |
|
||||
| 988 | `ID_COLOR_HUE_INC` |
|
||||
| 989 | `ID_COLOR_HUE_DEC` |
|
||||
| 990 | `ID_COLOR_SATURATION_INC` |
|
||||
| 991 | `ID_COLOR_SATURATION_DEC` |
|
||||
| 992 | `ID_COLOR_RESET` |
|
||||
| 994 | `ID_NORMALIZE` |
|
||||
| 997 | `ID_COPY_IMAGE` |
|
||||
| 1012 | `ID_SHIFT_SUB_DOWN` |
|
||||
| 1013 | `ID_SHIFT_SUB_UP` |
|
||||
| 1014 | `ID_GOTO_PREV_SUB` |
|
||||
| 1015 | `ID_GOTO_NEXT_SUB` |
|
||||
| 1021 | `ID_SHADERS_1_ENABLE` |
|
||||
| 1022 | `ID_SHADERS_2_ENABLE` |
|
||||
| 1036 | `ID_OSD_LOCAL_TIME` |
|
||||
| 1037 | `ID_OSD_FILE_NAME` |
|
||||
| 1039 | `ID_SHADERS_SELECT` |
|
||||
| 1100 | `ID_SUB_POS_UP` |
|
||||
| 1101 | `ID_SUB_POS_DOWN` |
|
||||
| 1102 | `ID_SUB_POS_LEFT` |
|
||||
| 1103 | `ID_SUB_POS_RIGHT` |
|
||||
| 1104 | `ID_SUB_POS_RESTORE` |
|
||||
| 1106 | `ID_SUB_COPYTOCLIPBOARD` |
|
||||
| 1107 | `ID_SUB_SIZE_DEC` |
|
||||
| 1108 | `ID_SUB_SIZE_INC` |
|
||||
| 1110 | `ID_STEREO3D_AUTO` |
|
||||
| 1111 | `ID_STEREO3D_MONO` |
|
||||
| 1112 | `ID_STEREO3D_ROW_INTERLEAVED` |
|
||||
| 1113 | `ID_STEREO3D_ROW_INTERLEAVED_2X` |
|
||||
| 1114 | `ID_STEREO3D_HALFOVERUNDER` |
|
||||
| 1115 | `ID_STEREO3D_OVERUNDER` |
|
||||
| 1120 | `ID_STEREO3D_SWAP_LEFTRIGHT` |
|
||||
| 1200 | `ID_SHOW_MILLISECONDS` |
|
||||
| 1210 | `ID_ADDTOPLAYLISTROMCLIPBOARD` |
|
||||
| 1211 | `ID_MOVEWINDOWBYVIDEO_ONOFF` |
|
||||
| 1212 | `ID_PLAYLIST_OPENFOLDER` |
|
||||
|
||||
## Practical implications for the web API
|
||||
|
||||
Because `OnCommand()` forwards any positive `wm_command` to `WM_COMMAND`, the effective web API surface is:
|
||||
|
||||
- all positive command IDs from the `resource.h` command block
|
||||
- plus the two special web-only commands:
|
||||
- `-1` seek
|
||||
- `-2` set volume
|
||||
|
||||
That means the following are all valid examples:
|
||||
|
||||
```text
|
||||
/command.html?wm_command=887
|
||||
/command.html?wm_command=889
|
||||
/command.html?wm_command=899
|
||||
/command.html?wm_command=902
|
||||
/command.html?wm_command=907
|
||||
/command.html?wm_command=909
|
||||
/command.html?wm_command=921
|
||||
/command.html?wm_command=952
|
||||
/command.html?wm_command=1171
|
||||
```
|
||||
|
||||
## Known good examples
|
||||
|
||||
### Play
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=887
|
||||
```
|
||||
|
||||
### Pause
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=888
|
||||
```
|
||||
|
||||
### Toggle play/pause
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=889
|
||||
```
|
||||
|
||||
### Stop
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=890
|
||||
```
|
||||
|
||||
### Exit player
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=816
|
||||
```
|
||||
|
||||
### Small backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=899
|
||||
```
|
||||
|
||||
### Small forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=900
|
||||
```
|
||||
|
||||
### Medium backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=901
|
||||
```
|
||||
|
||||
### Medium forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=902
|
||||
```
|
||||
|
||||
### Large backward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=903
|
||||
```
|
||||
|
||||
### Large forward seek
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=904
|
||||
```
|
||||
|
||||
### Volume up / down / mute
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=907
|
||||
GET /command.html?wm_command=908
|
||||
GET /command.html?wm_command=909
|
||||
```
|
||||
|
||||
### Absolute seek to position
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-1&position=00:12:30
|
||||
```
|
||||
|
||||
### Absolute seek by percent
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-1&percent=25
|
||||
```
|
||||
|
||||
### Set absolute volume
|
||||
|
||||
```text
|
||||
GET /command.html?wm_command=-2&volume=80
|
||||
```
|
||||
|
||||
### Read live player state
|
||||
|
||||
```text
|
||||
GET /variables.html
|
||||
```
|
||||
|
||||
## MediaHive integration notes
|
||||
|
||||
Current MediaHive integration uses the MPC-BE web interface in two ways:
|
||||
|
||||
- frontend status indication through MediaHive's backend proxy
|
||||
- native Python control in `mediahive.winmain`, which sends MPC-BE web requests directly
|
||||
|
||||
Current native command usage is centered on:
|
||||
|
||||
- `889` for play/pause
|
||||
- `816` for exit
|
||||
- `-1&position=HH:MM:SS` for exact 4-second seeking
|
||||
|
||||
## Guidance
|
||||
|
||||
- Prefer source-backed IDs from `resource.h` over icon inference from the HTML pages.
|
||||
- Prefer `-1&position=...` or `-1&percent=...` when deterministic seek positioning is needed.
|
||||
- Use `/variables.html` for timing and state.
|
||||
- Treat the web interface as version-dependent: the command list here is accurate for the inspected upstream `master` branch and may differ across releases.
|
||||
+80
-1
@@ -32,6 +32,7 @@
|
||||
<Header
|
||||
:current-view="currentView"
|
||||
:search-query="searchQuery"
|
||||
:mpc-be-connected="mpcBeConnected"
|
||||
:nav-row="1"
|
||||
:position="headerPosition"
|
||||
@search="searchQuery = $event"
|
||||
@@ -66,6 +67,7 @@
|
||||
v-else-if="selectedItem"
|
||||
:item="selectedItem"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@close="closeDetail"
|
||||
@play="handlePlay"
|
||||
@open-folder="handleOpenFolder"
|
||||
@@ -83,6 +85,7 @@
|
||||
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
|
||||
:items="movieCollageItems"
|
||||
:featured-item="movieFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -110,6 +113,7 @@
|
||||
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
|
||||
:items="seriesCollageItems"
|
||||
:featured-item="seriesFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -141,6 +145,7 @@
|
||||
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
|
||||
:items="searchCollageItems"
|
||||
:featured-item="searchFeaturedItem"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@play="handlePlay"
|
||||
@info="showDetail"
|
||||
@select="showDetail"
|
||||
@@ -179,7 +184,7 @@
|
||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { useRouter, useRoute } from 'vue-router';
|
||||
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types';
|
||||
import { playMedia, openFolder } from './api';
|
||||
import { playMedia, openFolder, isMpcBeReachable, fetchResumePositions, normalizeMediaPath } from './api';
|
||||
import { useKeyboardNavigation } from './composables/useKeyboardNavigation';
|
||||
import { useMediaWebSocket } from './composables/useMediaWebSocket';
|
||||
import Header from './components/Header.vue';
|
||||
@@ -201,6 +206,70 @@ const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values()))
|
||||
|
||||
const searchResults = ref<MediaItem[]>([]);
|
||||
const isSearching = ref(false);
|
||||
const mpcBeConnected = ref(false);
|
||||
const resumePositions = ref<Record<string, number>>({});
|
||||
const MPC_BE_OPENING_GRACE_MS = 4000;
|
||||
const mpcBeOpeningUntil = ref(0);
|
||||
let mpcBePollTimer: number | null = null;
|
||||
|
||||
function isMpcBeGamepadCaptured() {
|
||||
return mpcBeConnected.value || Date.now() < mpcBeOpeningUntil.value;
|
||||
}
|
||||
|
||||
function stopMpcBePolling() {
|
||||
if (mpcBePollTimer !== null) {
|
||||
window.clearInterval(mpcBePollTimer);
|
||||
mpcBePollTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshResumePositions() {
|
||||
resumePositions.value = await fetchResumePositions();
|
||||
}
|
||||
|
||||
function hasResumePosition(filePath: string | null) {
|
||||
if (!filePath) return false;
|
||||
const normalizedPath = normalizeMediaPath(filePath);
|
||||
return Number(resumePositions.value[normalizedPath] || 0) > 0;
|
||||
}
|
||||
|
||||
function startMpcBePolling() {
|
||||
if (mpcBePollTimer !== null) return;
|
||||
mpcBePollTimer = window.setInterval(async () => {
|
||||
const reachable = await isMpcBeReachable();
|
||||
const wasConnected = mpcBeConnected.value;
|
||||
mpcBeConnected.value = reachable;
|
||||
if (!reachable) {
|
||||
stopMpcBePolling();
|
||||
if (wasConnected) {
|
||||
void refreshResumePositions();
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
async function tryConnectMpcBe(attempts = 8, delayMs = 400): Promise<boolean> {
|
||||
for (let i = 0; i < attempts; i++) {
|
||||
const reachable = await isMpcBeReachable();
|
||||
if (reachable) return true;
|
||||
if (i < attempts - 1) {
|
||||
await new Promise(resolve => window.setTimeout(resolve, delayMs));
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type GamepadAction = 'up' | 'down' | 'left' | 'right' | 'select' | 'back';
|
||||
|
||||
function onGamepadAction(event: Event) {
|
||||
const customEvent = event as CustomEvent<{ action?: GamepadAction }>;
|
||||
const action = customEvent.detail?.action;
|
||||
if (!action) return;
|
||||
|
||||
if (isMpcBeGamepadCaptured()) {
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
// Focus episode info for navigating to series detail from search
|
||||
const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null);
|
||||
@@ -311,13 +380,17 @@ function handleEscapeKey(event: KeyboardEvent) {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void refreshResumePositions();
|
||||
document.addEventListener('keydown', handleEscapeKey);
|
||||
window.addEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
||||
window.addEventListener('click', requestInitialFullscreen, { once: true });
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('keydown', handleEscapeKey);
|
||||
window.removeEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
|
||||
window.removeEventListener('click', requestInitialFullscreen);
|
||||
stopMpcBePolling();
|
||||
});
|
||||
|
||||
// Search query stored in ref (not URL-based)
|
||||
@@ -1110,8 +1183,14 @@ function reloadPage() {
|
||||
}
|
||||
|
||||
async function handlePlay(filePath: string) {
|
||||
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS;
|
||||
try {
|
||||
await playMedia(filePath);
|
||||
const connected = await tryConnectMpcBe();
|
||||
if (connected) {
|
||||
mpcBeConnected.value = true;
|
||||
startMpcBePolling();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to play media:', e);
|
||||
}
|
||||
|
||||
+39
-4
@@ -1,5 +1,12 @@
|
||||
import type { MediaIndex } from './types';
|
||||
|
||||
export function normalizeMediaPath(input: string): string {
|
||||
return input
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/^[A-Za-z]:\//, '')
|
||||
.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the media index from the server
|
||||
*/
|
||||
@@ -15,11 +22,12 @@ export async function loadMediaIndex(): Promise<MediaIndex> {
|
||||
* Play a media file with the system's default player
|
||||
*/
|
||||
export async function playMedia(filePath: string): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(filePath);
|
||||
try {
|
||||
const response = await fetch('/api/play', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ file_path: filePath }),
|
||||
body: JSON.stringify({ file_path: normalizedPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -27,7 +35,7 @@ export async function playMedia(filePath: string): Promise<void> {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Play media error:', e);
|
||||
alert(`Failed to play: ${e}`);
|
||||
alert(`Failed to play media.\n\n${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,11 +43,12 @@ export async function playMedia(filePath: string): Promise<void> {
|
||||
* Open a folder in Windows Explorer
|
||||
*/
|
||||
export async function openFolder(folderPath: string): Promise<void> {
|
||||
const normalizedPath = normalizeMediaPath(folderPath);
|
||||
try {
|
||||
const response = await fetch('/api/open-folder', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ folder_path: folderPath }),
|
||||
body: JSON.stringify({ folder_path: normalizedPath }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
@@ -47,7 +56,33 @@ export async function openFolder(folderPath: string): Promise<void> {
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Open folder error:', e);
|
||||
alert(`Failed to open folder: ${e}`);
|
||||
alert(`Failed to open folder.\n\n${e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return whether MPC-BE local web control is currently reachable.
|
||||
*/
|
||||
export async function isMpcBeReachable(): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch('/api/mpcbe/status');
|
||||
if (!response.ok) return false;
|
||||
const data = await response.json().catch(() => ({}));
|
||||
return Boolean(data.reachable);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchResumePositions(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const response = await fetch('/api/playback/resume-positions');
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json().catch(() => ({}));
|
||||
const resumePositions = data?.resume_positions;
|
||||
return resumePositions && typeof resumePositions === 'object' ? resumePositions : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
</div>
|
||||
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
|
||||
<div class="collage-buttons">
|
||||
<button class="btn btn-primary" @click.stop="handlePlay(item)">▶ Play</button>
|
||||
<button class="btn btn-primary" @click.stop="handlePlay(item)">▶ {{ getPlayLabel(item) }}</button>
|
||||
<button class="btn btn-secondary" @click.stop="$emit('info', item)">ℹ Info</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -416,6 +416,7 @@ function isItemVisible(index: number): boolean {
|
||||
const props = defineProps<{
|
||||
items: MediaItem[];
|
||||
featuredItem?: MediaItem | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -530,6 +531,10 @@ function handlePlay(item: MediaItem) {
|
||||
if (file) emit('play', file);
|
||||
}
|
||||
|
||||
function getPlayLabel(item: MediaItem): string {
|
||||
return props.hasResumePosition(getPlayableFile(item)) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
function handleItemClick(item: MediaItem, index: number) {
|
||||
if (index === 0) {
|
||||
emit('info', item);
|
||||
|
||||
@@ -57,6 +57,11 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="mpcBeConnected" class="player-indicator" title="MPC-BE is connected">
|
||||
<span class="player-indicator-dot" aria-hidden="true"></span>
|
||||
<span>Player Open</span>
|
||||
</div>
|
||||
|
||||
<div v-if="isDesktopApp" class="header-settings">
|
||||
<button
|
||||
class="header-settings-btn"
|
||||
@@ -81,6 +86,7 @@ import { pickFolderAndRestart } from '../api';
|
||||
const props = defineProps<{
|
||||
currentView: 'movies' | 'series';
|
||||
searchQuery: string;
|
||||
mpcBeConnected: boolean;
|
||||
navRow: number;
|
||||
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
|
||||
}>();
|
||||
@@ -180,3 +186,22 @@ onUnmounted(() => {
|
||||
window.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.player-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-right: 10px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.player-indicator-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #22c55e;
|
||||
box-shadow: 0 0 0 4px rgba(34, 197, 94, 0.18);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
v-if="item.type === 'series'"
|
||||
:series="item.data as Series"
|
||||
:focus-episode="focusEpisode"
|
||||
:has-resume-position="hasResumePosition"
|
||||
@close="$emit('close')"
|
||||
@play="handlePlay"
|
||||
@openFolder="handleOpenFolder"
|
||||
@@ -95,7 +96,7 @@
|
||||
v-bind="navAttrs(2, index * 2)"
|
||||
@click="handlePlay(version.playable_file)"
|
||||
:disabled="!version.playable_file"
|
||||
>▶ Play</button>
|
||||
>▶ {{ getPlayLabel(version.playable_file) }}</button>
|
||||
<button
|
||||
class="btn btn-small btn-secondary"
|
||||
v-bind="navAttrs(2, index * 2 + 1)"
|
||||
@@ -148,6 +149,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
const props = defineProps<{
|
||||
item: MediaItem;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -394,6 +396,10 @@ function handlePlay(filePath: string | null) {
|
||||
}
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
emit('openFolder', folderPath);
|
||||
}
|
||||
|
||||
@@ -138,7 +138,7 @@
|
||||
tabindex="0"
|
||||
@click="handlePlayVersion(torrent.playable_file)"
|
||||
:disabled="!torrent.playable_file"
|
||||
>▶ Play</button>
|
||||
>▶ {{ getPlayLabel(torrent.playable_file) }}</button>
|
||||
<button
|
||||
class="ctx-btn ctx-btn-folder"
|
||||
tabindex="0"
|
||||
@@ -164,6 +164,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
|
||||
const props = defineProps<{
|
||||
series: Series;
|
||||
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
|
||||
hasResumePosition: (filePath: string | null) => boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -302,6 +303,10 @@ function handlePlayVersion(filePath: string | null) {
|
||||
closeContextMenu();
|
||||
}
|
||||
|
||||
function getPlayLabel(filePath: string | null): string {
|
||||
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
|
||||
}
|
||||
|
||||
// Open folder for a version
|
||||
function handleOpenFolder(folderPath: string) {
|
||||
emit('openFolder', folderPath);
|
||||
|
||||
@@ -54,6 +54,13 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb
|
||||
if (!canTrigger) return;
|
||||
|
||||
gamepadLastTriggerAt[action] = now;
|
||||
const actionEvent = new CustomEvent('mediahive:gamepad-action', {
|
||||
detail: { action },
|
||||
cancelable: true,
|
||||
});
|
||||
const shouldContinueWithKeyboard = window.dispatchEvent(actionEvent);
|
||||
if (!shouldContinueWithKeyboard) return;
|
||||
|
||||
dispatchKey(KEY_BY_ACTION[action]);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,11 +7,14 @@ pipeline with live WebSocket updates. Excluded paths are controlled by
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import mimetypes
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
@@ -146,6 +149,27 @@ def normalize_path(url_path: str) -> Path:
|
||||
return MEDIAROOT / clean_path
|
||||
|
||||
|
||||
def _load_resume_positions() -> dict[str, int]:
|
||||
if MEDIAROOT is None:
|
||||
return {}
|
||||
|
||||
playback_state_path = MEDIAROOT / ".mediahive" / "playback-state.json"
|
||||
try:
|
||||
raw = json.loads(playback_state_path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
resume_positions = raw.get("resume_positions") if isinstance(raw, dict) else None
|
||||
if not isinstance(resume_positions, dict):
|
||||
return {}
|
||||
|
||||
cleaned: dict[str, int] = {}
|
||||
for key, value in resume_positions.items():
|
||||
if isinstance(key, str) and isinstance(value, (int, float)):
|
||||
cleaned[key] = max(0, int(value))
|
||||
return cleaned
|
||||
|
||||
|
||||
# === API Endpoints ===
|
||||
|
||||
|
||||
@@ -231,6 +255,12 @@ async def get_index():
|
||||
return MsgspecResponse(store.get_full_index())
|
||||
|
||||
|
||||
@app.get("/api/playback/resume-positions")
|
||||
async def playback_resume_positions():
|
||||
"""Return saved per-file resume positions under the current media root."""
|
||||
return {"resume_positions": _load_resume_positions()}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Scanning API (active when HIVESCAN_PATHS is configured)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -350,6 +380,23 @@ async def open_folder(request: Request):
|
||||
raise HTTPException(status_code=500, detail=f"Failed to open folder: {e}")
|
||||
|
||||
|
||||
def _mpcbe_request(path: str, timeout: float = 0.75) -> bool:
|
||||
"""Call MPC-BE's local web interface and return True on HTTP success."""
|
||||
url = f"http://127.0.0.1:13579{path}"
|
||||
req = urllib.request.Request(url=url, method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
@app.get("/api/mpcbe/status")
|
||||
async def mpcbe_status():
|
||||
"""Check whether MPC-BE web interface is reachable."""
|
||||
return {"reachable": _mpcbe_request("/")}
|
||||
|
||||
|
||||
@app.get("/api/media/{file_path:path}")
|
||||
async def serve_media_file(file_path: str):
|
||||
"""
|
||||
|
||||
@@ -5,11 +5,18 @@ Or from PyInstaller: MediaHive.exe [media_folder]
|
||||
"""
|
||||
|
||||
import argparse
|
||||
from concurrent.futures import Future, ThreadPoolExecutor
|
||||
import ctypes
|
||||
import html
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
|
||||
@@ -24,6 +31,507 @@ BACKEND_HOST = "127.0.0.1"
|
||||
BACKEND_PORT = 8420
|
||||
BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
|
||||
HEALTH_TIMEOUT = 2 # seconds
|
||||
MPC_BE_URL = "http://127.0.0.1:13579"
|
||||
GAMEPAD_REPEAT_SECONDS = 0.008
|
||||
GAMEPAD_POLL_SECONDS = 0.008
|
||||
MPC_BE_FRAME_REPEAT_SECONDS = 0.016
|
||||
MPC_BE_SEEK_BEGIN_HOLD_SECONDS = 1.0
|
||||
MPC_BE_REQUEST_TIMEOUT = 0.15
|
||||
MPC_BE_MAX_INFLIGHT_REQUESTS = 12
|
||||
MPC_BE_REQUEST_WORKERS = 4
|
||||
MPC_BE_STATUS_POLL_SECONDS = 0.1
|
||||
MPC_BE_STATUS_MISS_THRESHOLD = 5
|
||||
MPC_BE_STATE_STOPPED = 0
|
||||
MPC_BE_STATE_PAUSED = 1
|
||||
MPC_BE_STATE_RUNNING = 2
|
||||
MPC_BE_SEEK_BEGIN_COMMAND = 1085
|
||||
MPC_BE_RESUME_APPLY_THRESHOLD_MS = 15000
|
||||
MPC_BE_RESUME_CLEAR_MARGIN_MS = 15000
|
||||
MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS = 1.0
|
||||
|
||||
|
||||
class _XINPUT_GAMEPAD(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("wButtons", ctypes.c_ushort),
|
||||
("bLeftTrigger", ctypes.c_ubyte),
|
||||
("bRightTrigger", ctypes.c_ubyte),
|
||||
("sThumbLX", ctypes.c_short),
|
||||
("sThumbLY", ctypes.c_short),
|
||||
("sThumbRX", ctypes.c_short),
|
||||
("sThumbRY", ctypes.c_short),
|
||||
]
|
||||
|
||||
|
||||
class _XINPUT_STATE(ctypes.Structure):
|
||||
_fields_ = [
|
||||
("dwPacketNumber", ctypes.c_ulong),
|
||||
("Gamepad", _XINPUT_GAMEPAD),
|
||||
]
|
||||
|
||||
|
||||
_XINPUT_BUTTONS = {
|
||||
0x0001: "DPAD_UP",
|
||||
0x0002: "DPAD_DOWN",
|
||||
0x0004: "DPAD_LEFT",
|
||||
0x0008: "DPAD_RIGHT",
|
||||
0x0010: "START",
|
||||
0x0020: "BACK",
|
||||
0x0040: "L3",
|
||||
0x0080: "R3",
|
||||
0x0100: "LB",
|
||||
0x0200: "RB",
|
||||
0x1000: "A",
|
||||
0x2000: "B",
|
||||
0x4000: "X",
|
||||
0x8000: "Y",
|
||||
}
|
||||
|
||||
_MPC_BE_COMMANDS = {
|
||||
0x0001: 907,
|
||||
0x0002: 908,
|
||||
0x1000: 889,
|
||||
0x2000: 816,
|
||||
0x8000: 909,
|
||||
}
|
||||
|
||||
_MPC_BE_SEEK_MASK_TO_COMMANDS = {
|
||||
0x0004: (892, 901),
|
||||
0x0008: (891, 902),
|
||||
}
|
||||
|
||||
_MPC_BE_REPEATABLE_MASKS = {
|
||||
0x0001,
|
||||
0x0002,
|
||||
*_MPC_BE_SEEK_MASK_TO_COMMANDS,
|
||||
}
|
||||
|
||||
_STATE_RE = re.compile(r'<p id="state">(\d+)</p>')
|
||||
_FILEPATH_RE = re.compile(r'<p id="filepath">(.*?)</p>', re.DOTALL)
|
||||
_POSITION_RE = re.compile(r'<p id="position">(\d+)</p>')
|
||||
_DURATION_RE = re.compile(r'<p id="duration">(\d+)</p>')
|
||||
|
||||
|
||||
def _default_playback_state() -> dict[str, object]:
|
||||
return {
|
||||
"current": None,
|
||||
"resume_positions": {},
|
||||
}
|
||||
|
||||
|
||||
def _load_playback_state(path: Path) -> dict[str, object]:
|
||||
try:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception:
|
||||
return _default_playback_state()
|
||||
|
||||
if not isinstance(raw, dict):
|
||||
return _default_playback_state()
|
||||
|
||||
current = raw.get("current")
|
||||
resume_positions = raw.get("resume_positions")
|
||||
normalized: dict[str, object] = {
|
||||
"current": current if isinstance(current, dict) else None,
|
||||
"resume_positions": {},
|
||||
}
|
||||
|
||||
if isinstance(resume_positions, dict):
|
||||
cleaned_positions: dict[str, int] = {}
|
||||
for key, value in resume_positions.items():
|
||||
if isinstance(key, str) and isinstance(value, (int, float)):
|
||||
cleaned_positions[key] = max(0, int(value))
|
||||
normalized["resume_positions"] = cleaned_positions
|
||||
|
||||
return normalized
|
||||
|
||||
|
||||
def _save_playback_state(path: Path, state: dict[str, object]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(f"{path.suffix}.tmp")
|
||||
tmp_path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8")
|
||||
tmp_path.replace(path)
|
||||
|
||||
|
||||
def _media_key_for_filepath(filepath: str, media_root: Path) -> str | None:
|
||||
try:
|
||||
relative = Path(filepath).resolve().relative_to(media_root.resolve())
|
||||
except Exception:
|
||||
return None
|
||||
return relative.as_posix()
|
||||
|
||||
|
||||
def _should_clear_resume(position_ms: int, duration_ms: int) -> bool:
|
||||
if position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS:
|
||||
return True
|
||||
if duration_ms <= 0:
|
||||
return False
|
||||
return duration_ms - position_ms <= MPC_BE_RESUME_CLEAR_MARGIN_MS
|
||||
|
||||
|
||||
def _load_xinput_get_state():
|
||||
"""Load XInputGetState from available XInput DLLs (XInput only)."""
|
||||
candidates = ["xinput1_4.dll", "xinput9_1_0.dll", "xinput1_3.dll"]
|
||||
for dll_name in candidates:
|
||||
try:
|
||||
dll = ctypes.WinDLL(dll_name)
|
||||
fn = dll.XInputGetState
|
||||
fn.argtypes = [ctypes.c_uint, ctypes.POINTER(_XINPUT_STATE)]
|
||||
fn.restype = ctypes.c_ulong
|
||||
return fn
|
||||
except Exception:
|
||||
continue
|
||||
raise RuntimeError("XInput DLL not found")
|
||||
|
||||
|
||||
def _mpcbe_request(path: str, timeout: float = MPC_BE_REQUEST_TIMEOUT) -> bool:
|
||||
"""Call MPC-BE's local web interface and return True on HTTP success."""
|
||||
req = urllib.request.Request(url=f"{MPC_BE_URL}{path}", method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return 200 <= resp.status < 300
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _send_mpcbe_command(command_id: int) -> bool:
|
||||
return _mpcbe_request(f"/command.html?wm_command={command_id}")
|
||||
|
||||
|
||||
def _format_mpcbe_position(position_ms: int) -> str:
|
||||
total_seconds = max(0, position_ms // 1000)
|
||||
hours, remainder = divmod(total_seconds, 3600)
|
||||
minutes, seconds = divmod(remainder, 60)
|
||||
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
||||
|
||||
|
||||
def _seek_mpcbe_to_position(position_ms: int) -> bool:
|
||||
query = urllib.parse.urlencode(
|
||||
{
|
||||
"wm_command": -1,
|
||||
"position": _format_mpcbe_position(position_ms),
|
||||
}
|
||||
)
|
||||
return _mpcbe_request(f"/command.html?{query}")
|
||||
|
||||
|
||||
def _mpcbe_fetch_status() -> tuple[str, int, int, int] | None:
|
||||
"""Fetch current file path, position, duration, and playback state from MPC-BE."""
|
||||
req = urllib.request.Request(url=f"{MPC_BE_URL}/variables.html", method="GET")
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=MPC_BE_REQUEST_TIMEOUT) as resp:
|
||||
response_html = resp.read().decode("utf-8", errors="replace")
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
return None
|
||||
|
||||
state_match = _STATE_RE.search(response_html)
|
||||
filepath_match = _FILEPATH_RE.search(response_html)
|
||||
position_match = _POSITION_RE.search(response_html)
|
||||
duration_match = _DURATION_RE.search(response_html)
|
||||
if not state_match or not position_match or not duration_match:
|
||||
return None
|
||||
|
||||
filepath = html.unescape(filepath_match.group(1)).strip() if filepath_match else ""
|
||||
return (
|
||||
filepath,
|
||||
int(position_match.group(1)),
|
||||
int(duration_match.group(1)),
|
||||
int(state_match.group(1)),
|
||||
)
|
||||
|
||||
|
||||
def _start_gamepad_remote(stop_event: threading.Event, media_root: Path) -> threading.Thread:
|
||||
"""Start background XInput polling and send mapped commands to MPC-BE."""
|
||||
get_state = _load_xinput_get_state()
|
||||
last_connected = [False, False, False, False]
|
||||
last_pressed_masks = [0, 0, 0, 0]
|
||||
seek_begin_hold_started_at: list[float | None] = [None, None, None, None]
|
||||
seek_begin_fired = [False, False, False, False]
|
||||
last_repeat_at = [
|
||||
{
|
||||
mask: 0.0
|
||||
for mask in (*_MPC_BE_COMMANDS.keys(), *_MPC_BE_SEEK_MASK_TO_COMMANDS.keys())
|
||||
}
|
||||
for _ in range(4)
|
||||
]
|
||||
request_pool = ThreadPoolExecutor(
|
||||
max_workers=MPC_BE_REQUEST_WORKERS,
|
||||
thread_name_prefix="mediahive-mpcbe",
|
||||
)
|
||||
pending_requests: list[Future[bool]] = []
|
||||
status_lock = threading.Lock()
|
||||
status_future: Future[tuple[str, int, int, int] | None] | None = None
|
||||
player_filepath = ""
|
||||
player_position_ms: int | None = None
|
||||
player_duration_ms: int | None = None
|
||||
player_state: int | None = None
|
||||
status_updated_at = 0.0
|
||||
status_miss_count = 0
|
||||
playback_state_path = media_root / ".mediahive" / "playback-state.json"
|
||||
playback_state = _load_playback_state(playback_state_path)
|
||||
resume_positions = playback_state["resume_positions"]
|
||||
if not isinstance(resume_positions, dict):
|
||||
resume_positions = {}
|
||||
playback_state["resume_positions"] = resume_positions
|
||||
if playback_state.get("current") is not None:
|
||||
playback_state["current"] = None
|
||||
_save_playback_state(playback_state_path, playback_state)
|
||||
tracked_media_key: str | None = None
|
||||
tracked_filepath = ""
|
||||
resume_applied_for_key: str | None = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
|
||||
def queue_command(command_id: int) -> None:
|
||||
pending_requests.append(request_pool.submit(_send_mpcbe_command, command_id))
|
||||
|
||||
def queue_seek_to_position(position_ms: int) -> None:
|
||||
pending_requests.append(request_pool.submit(_seek_mpcbe_to_position, position_ms))
|
||||
|
||||
def flush_playback_state() -> None:
|
||||
_save_playback_state(playback_state_path, playback_state)
|
||||
|
||||
def clear_tracked_current(*, clear_resume_applied: bool) -> None:
|
||||
nonlocal tracked_media_key, tracked_filepath, last_playback_state_flush_at, resume_applied_for_key
|
||||
if tracked_media_key is None and playback_state.get("current") is None:
|
||||
if clear_resume_applied:
|
||||
resume_applied_for_key = None
|
||||
return
|
||||
tracked_media_key = None
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
if clear_resume_applied:
|
||||
resume_applied_for_key = None
|
||||
flush_playback_state()
|
||||
|
||||
def finalize_tracked_current() -> None:
|
||||
nonlocal tracked_media_key, tracked_filepath, resume_applied_for_key, last_playback_state_flush_at
|
||||
if tracked_media_key is None:
|
||||
if playback_state.get("current") is not None:
|
||||
playback_state["current"] = None
|
||||
flush_playback_state()
|
||||
return
|
||||
|
||||
position_ms = player_position_ms or 0
|
||||
duration_ms = player_duration_ms or 0
|
||||
if _should_clear_resume(position_ms, duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
else:
|
||||
resume_positions[tracked_media_key] = position_ms
|
||||
|
||||
tracked_media_key = None
|
||||
tracked_filepath = ""
|
||||
playback_state["current"] = None
|
||||
resume_applied_for_key = None
|
||||
last_playback_state_flush_at = 0.0
|
||||
flush_playback_state()
|
||||
|
||||
def persist_tracked_current(now: float, *, force: bool = False) -> None:
|
||||
nonlocal last_playback_state_flush_at
|
||||
if tracked_media_key is None:
|
||||
return
|
||||
if not force and now - last_playback_state_flush_at < MPC_BE_PLAYBACK_STATE_FLUSH_SECONDS:
|
||||
return
|
||||
|
||||
playback_state["current"] = {
|
||||
"file_key": tracked_media_key,
|
||||
"file_path": tracked_filepath,
|
||||
"position_ms": player_position_ms or 0,
|
||||
"duration_ms": player_duration_ms or 0,
|
||||
"updated_at": int(time.time()),
|
||||
}
|
||||
last_playback_state_flush_at = now
|
||||
flush_playback_state()
|
||||
|
||||
def maybe_apply_resume(now: float) -> None:
|
||||
nonlocal player_position_ms, resume_applied_for_key
|
||||
if tracked_media_key is None:
|
||||
return
|
||||
if resume_applied_for_key == tracked_media_key:
|
||||
return
|
||||
|
||||
saved_position = resume_positions.get(tracked_media_key)
|
||||
if not isinstance(saved_position, int):
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if player_position_ms is None or player_duration_ms is None:
|
||||
return
|
||||
if player_position_ms > MPC_BE_RESUME_APPLY_THRESHOLD_MS:
|
||||
resume_applied_for_key = tracked_media_key
|
||||
return
|
||||
if _should_clear_resume(saved_position, player_duration_ms):
|
||||
resume_positions.pop(tracked_media_key, None)
|
||||
resume_applied_for_key = tracked_media_key
|
||||
flush_playback_state()
|
||||
return
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
return
|
||||
|
||||
target_ms = min(saved_position, max(player_duration_ms - 1000, 0))
|
||||
queue_seek_to_position(target_ms)
|
||||
player_position_ms = target_ms
|
||||
resume_applied_for_key = tracked_media_key
|
||||
persist_tracked_current(now, force=True)
|
||||
|
||||
def update_status_from_future() -> None:
|
||||
nonlocal status_future, player_filepath, player_position_ms, player_duration_ms, player_state
|
||||
nonlocal status_updated_at, status_miss_count, tracked_media_key, tracked_filepath
|
||||
nonlocal resume_applied_for_key
|
||||
if status_future is None or not status_future.done():
|
||||
return
|
||||
|
||||
try:
|
||||
status = status_future.result()
|
||||
except Exception:
|
||||
status = None
|
||||
status_future = None
|
||||
|
||||
if status is None:
|
||||
status_miss_count += 1
|
||||
if status_miss_count >= MPC_BE_STATUS_MISS_THRESHOLD:
|
||||
finalize_tracked_current()
|
||||
with status_lock:
|
||||
player_filepath = ""
|
||||
player_position_ms = None
|
||||
player_duration_ms = None
|
||||
player_state = None
|
||||
status_updated_at = 0.0
|
||||
return
|
||||
|
||||
status_miss_count = 0
|
||||
|
||||
filepath, position_ms, duration_ms, state = status
|
||||
media_key = _media_key_for_filepath(filepath, media_root) if filepath else None
|
||||
|
||||
if tracked_media_key is not None and media_key != tracked_media_key:
|
||||
finalize_tracked_current()
|
||||
|
||||
if media_key is None:
|
||||
clear_tracked_current(clear_resume_applied=True)
|
||||
elif tracked_media_key != media_key:
|
||||
tracked_media_key = media_key
|
||||
tracked_filepath = filepath
|
||||
resume_applied_for_key = None
|
||||
|
||||
player_filepath = filepath
|
||||
|
||||
with status_lock:
|
||||
player_position_ms = position_ms
|
||||
player_duration_ms = duration_ms
|
||||
player_state = state
|
||||
status_updated_at = time.monotonic()
|
||||
|
||||
maybe_apply_resume(status_updated_at)
|
||||
persist_tracked_current(status_updated_at)
|
||||
|
||||
def queue_status_refresh(now: float, *, force: bool = False) -> None:
|
||||
nonlocal status_future
|
||||
if status_future is not None:
|
||||
return
|
||||
|
||||
with status_lock:
|
||||
is_stale = now - status_updated_at >= MPC_BE_STATUS_POLL_SECONDS
|
||||
|
||||
if force or is_stale:
|
||||
status_future = request_pool.submit(_mpcbe_fetch_status)
|
||||
|
||||
def command_for_seek(mask: int) -> int:
|
||||
paused_command, seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
|
||||
with status_lock:
|
||||
is_paused = player_state == MPC_BE_STATE_PAUSED
|
||||
return paused_command if is_paused else seek_command
|
||||
|
||||
def repeat_seconds_for_seek(mask: int) -> float:
|
||||
paused_command, _seek_command = _MPC_BE_SEEK_MASK_TO_COMMANDS[mask]
|
||||
with status_lock:
|
||||
active_command = paused_command if player_state == MPC_BE_STATE_PAUSED else None
|
||||
return MPC_BE_FRAME_REPEAT_SECONDS if active_command == paused_command else GAMEPAD_REPEAT_SECONDS
|
||||
|
||||
def _run() -> None:
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
now = time.monotonic()
|
||||
pending_requests[:] = [future for future in pending_requests if not future.done()]
|
||||
update_status_from_future()
|
||||
queue_status_refresh(now)
|
||||
|
||||
for slot in range(4):
|
||||
state = _XINPUT_STATE()
|
||||
rc = get_state(slot, ctypes.byref(state))
|
||||
is_connected = rc == 0
|
||||
current_mask = state.Gamepad.wButtons if is_connected else 0
|
||||
|
||||
is_seek_begin_pressed = bool(current_mask & 0x4000)
|
||||
if is_seek_begin_pressed:
|
||||
if seek_begin_hold_started_at[slot] is None:
|
||||
seek_begin_hold_started_at[slot] = now
|
||||
seek_begin_fired[slot] = False
|
||||
elif (
|
||||
not seek_begin_fired[slot]
|
||||
and now - seek_begin_hold_started_at[slot] >= MPC_BE_SEEK_BEGIN_HOLD_SECONDS
|
||||
and len(pending_requests) < MPC_BE_MAX_INFLIGHT_REQUESTS
|
||||
):
|
||||
queue_command(MPC_BE_SEEK_BEGIN_COMMAND)
|
||||
seek_begin_fired[slot] = True
|
||||
else:
|
||||
seek_begin_hold_started_at[slot] = None
|
||||
seek_begin_fired[slot] = False
|
||||
|
||||
if is_connected != last_connected[slot]:
|
||||
last_connected[slot] = is_connected
|
||||
|
||||
for mask, command_id in _MPC_BE_COMMANDS.items():
|
||||
is_pressed = bool(current_mask & mask)
|
||||
was_pressed = bool(last_pressed_masks[slot] & mask)
|
||||
should_fire = is_pressed and not was_pressed
|
||||
|
||||
if (
|
||||
not should_fire
|
||||
and is_pressed
|
||||
and mask in _MPC_BE_REPEATABLE_MASKS
|
||||
and now - last_repeat_at[slot][mask] >= GAMEPAD_REPEAT_SECONDS
|
||||
):
|
||||
should_fire = True
|
||||
|
||||
if not should_fire:
|
||||
continue
|
||||
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
continue
|
||||
|
||||
queue_command(command_id)
|
||||
last_repeat_at[slot][mask] = now
|
||||
|
||||
for mask in _MPC_BE_SEEK_MASK_TO_COMMANDS:
|
||||
is_pressed = bool(current_mask & mask)
|
||||
was_pressed = bool(last_pressed_masks[slot] & mask)
|
||||
should_fire = is_pressed and not was_pressed
|
||||
repeat_seconds = repeat_seconds_for_seek(mask)
|
||||
|
||||
if (
|
||||
not should_fire
|
||||
and is_pressed
|
||||
and now - last_repeat_at[slot][mask] >= repeat_seconds
|
||||
):
|
||||
should_fire = True
|
||||
|
||||
if not should_fire:
|
||||
continue
|
||||
|
||||
if len(pending_requests) >= MPC_BE_MAX_INFLIGHT_REQUESTS:
|
||||
continue
|
||||
|
||||
queue_command(command_for_seek(mask))
|
||||
last_repeat_at[slot][mask] = now
|
||||
|
||||
last_pressed_masks[slot] = current_mask
|
||||
|
||||
stop_event.wait(GAMEPAD_POLL_SECONDS)
|
||||
finally:
|
||||
finalize_tracked_current()
|
||||
request_pool.shutdown(wait=False, cancel_futures=True)
|
||||
|
||||
thread = threading.Thread(target=_run, daemon=True, name="mediahive-gamepad-remote")
|
||||
thread.start()
|
||||
return thread
|
||||
|
||||
|
||||
def _setup_logging() -> Path:
|
||||
@@ -207,11 +715,21 @@ def winmain() -> None:
|
||||
js_api=api,
|
||||
)
|
||||
|
||||
poll_stop = threading.Event()
|
||||
poll_thread: threading.Thread | None = None
|
||||
|
||||
def on_shown() -> None:
|
||||
api._window = window
|
||||
nonlocal poll_thread
|
||||
if poll_thread is None:
|
||||
poll_thread = _start_gamepad_remote(poll_stop, mediaroot)
|
||||
|
||||
webview.start(func=on_shown, icon=_icon_path())
|
||||
|
||||
poll_stop.set()
|
||||
if poll_thread is not None:
|
||||
poll_thread.join(timeout=1)
|
||||
|
||||
server.should_exit = True
|
||||
backend_thread.join(timeout=10)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user