Add MPC-BE remote control and resume support.

This commit is contained in:
2026-05-16 22:25:54 +00:00
parent 76c4cc0829
commit c405ea98b3
10 changed files with 1356 additions and 8 deletions
+621
View File
@@ -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
View File
@@ -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
View File
@@ -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 {};
}
}
+6 -1
View File
@@ -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);
+25
View File
@@ -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>
+7 -1
View File
@@ -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);
}
+6 -1
View File
@@ -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]);
}
+47
View File
@@ -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):
"""
+518
View File
@@ -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)