5 Commits
Author SHA1 Message Date
LeoVasanko 5bb46c162e Implement synced category row scrolling. 2026-05-16 23:00:54 +00:00
LeoVasanko 81fbfee5d3 Fix hero gamepad focus activation. 2026-05-16 22:35:31 +00:00
LeoVasanko c405ea98b3 Add MPC-BE remote control and resume support. 2026-05-16 22:25:54 +00:00
LeoVasanko 76c4cc0829 Normalize relative media paths. 2026-05-16 22:25:52 +00:00
LeoVasanko c3f5ac2f6e Add Gitea release script
- Add Windows download link to README
2026-05-14 05:02:51 +00:00
17 changed files with 1817 additions and 31 deletions
+2
View File
@@ -2,6 +2,8 @@
Media scanning, indexing, and Netflix-style web streaming for your torrent collection. Media scanning, indexing, and Netflix-style web streaming for your torrent collection.
**[Windows portable ZIP download](https://git.zi.fi/LeoVasanko/mediahive/releases)**
## Project Structure ## Project Structure
``` ```
+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.
+82 -4
View File
@@ -32,6 +32,7 @@
<Header <Header
:current-view="currentView" :current-view="currentView"
:search-query="searchQuery" :search-query="searchQuery"
:mpc-be-connected="mpcBeConnected"
:nav-row="1" :nav-row="1"
:position="headerPosition" :position="headerPosition"
@search="searchQuery = $event" @search="searchQuery = $event"
@@ -66,6 +67,7 @@
v-else-if="selectedItem" v-else-if="selectedItem"
:item="selectedItem" :item="selectedItem"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
@close="closeDetail" @close="closeDetail"
@play="handlePlay" @play="handlePlay"
@open-folder="handleOpenFolder" @open-folder="handleOpenFolder"
@@ -83,6 +85,7 @@
:key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`" :key="`movie-hero-${movieCollageItems.length}-${movieFeaturedItem?.id || 'none'}`"
:items="movieCollageItems" :items="movieCollageItems"
:featured-item="movieFeaturedItem" :featured-item="movieFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay" @play="handlePlay"
@info="showDetail" @info="showDetail"
@select="showDetail" @select="showDetail"
@@ -110,6 +113,7 @@
:key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`" :key="`series-hero-${seriesCollageItems.length}-${seriesFeaturedItem?.id || 'none'}`"
:items="seriesCollageItems" :items="seriesCollageItems"
:featured-item="seriesFeaturedItem" :featured-item="seriesFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay" @play="handlePlay"
@info="showDetail" @info="showDetail"
@select="showDetail" @select="showDetail"
@@ -141,6 +145,7 @@
:key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`" :key="`search-hero-${searchCollageItems.length}-${searchFeaturedItem?.id || 'none'}`"
:items="searchCollageItems" :items="searchCollageItems"
:featured-item="searchFeaturedItem" :featured-item="searchFeaturedItem"
:has-resume-position="hasResumePosition"
@play="handlePlay" @play="handlePlay"
@info="showDetail" @info="showDetail"
@select="showDetail" @select="showDetail"
@@ -179,7 +184,7 @@
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'; import { ref, computed, onMounted, onUnmounted, watch } from 'vue';
import { useRouter, useRoute } from 'vue-router'; import { useRouter, useRoute } from 'vue-router';
import type { Movie, Series, MediaItem, EpisodeWithSeries, MatchedPerson, MatchedEpisode, TaskInfo } from './types'; 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 { useKeyboardNavigation } from './composables/useKeyboardNavigation';
import { useMediaWebSocket } from './composables/useMediaWebSocket'; import { useMediaWebSocket } from './composables/useMediaWebSocket';
import Header from './components/Header.vue'; import Header from './components/Header.vue';
@@ -188,7 +193,7 @@ import MediaRow from './components/MediaRow.vue';
import MediaDetail from './components/MediaDetail.vue'; import MediaDetail from './components/MediaDetail.vue';
// Initialize keyboard navigation // Initialize keyboard navigation
const { getFocusState, restoreFocusState, focusAt } = useKeyboardNavigation(); const { getFocusState, restoreFocusState, focusAt, focusElement } = useKeyboardNavigation();
const router = useRouter(); const router = useRouter();
const route = useRoute(); const route = useRoute();
@@ -201,6 +206,70 @@ const activeTasks = computed<TaskInfo[]>(() => Array.from(tasks.value.values()))
const searchResults = ref<MediaItem[]>([]); const searchResults = ref<MediaItem[]>([]);
const isSearching = ref(false); 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 // Focus episode info for navigating to series detail from search
const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null); const focusEpisode = ref<{ seasonNumber: number; episodeNumber: number } | null>(null);
@@ -246,8 +315,7 @@ function restoreFocusForPage(page: string) {
// Find the element with matching item id // Find the element with matching item id
const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null; const element = document.querySelector(`[data-item-id="${itemId}"]`) as HTMLElement | null;
if (element) { if (element) {
element.focus(); focusElement(element);
element.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'nearest' });
lastViewedItemId.value = null; lastViewedItemId.value = null;
return; return;
} }
@@ -311,13 +379,17 @@ function handleEscapeKey(event: KeyboardEvent) {
} }
onMounted(() => { onMounted(() => {
void refreshResumePositions();
document.addEventListener('keydown', handleEscapeKey); document.addEventListener('keydown', handleEscapeKey);
window.addEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
window.addEventListener('click', requestInitialFullscreen, { once: true }); window.addEventListener('click', requestInitialFullscreen, { once: true });
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', handleEscapeKey); document.removeEventListener('keydown', handleEscapeKey);
window.removeEventListener('mediahive:gamepad-action', onGamepadAction as EventListener);
window.removeEventListener('click', requestInitialFullscreen); window.removeEventListener('click', requestInitialFullscreen);
stopMpcBePolling();
}); });
// Search query stored in ref (not URL-based) // Search query stored in ref (not URL-based)
@@ -1110,8 +1182,14 @@ function reloadPage() {
} }
async function handlePlay(filePath: string) { async function handlePlay(filePath: string) {
mpcBeOpeningUntil.value = Date.now() + MPC_BE_OPENING_GRACE_MS;
try { try {
await playMedia(filePath); await playMedia(filePath);
const connected = await tryConnectMpcBe();
if (connected) {
mpcBeConnected.value = true;
startMpcBePolling();
}
} catch (e) { } catch (e) {
console.error('Failed to play media:', e); console.error('Failed to play media:', e);
} }
+39 -4
View File
@@ -1,5 +1,12 @@
import type { MediaIndex } from './types'; 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 * 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 * Play a media file with the system's default player
*/ */
export async function playMedia(filePath: string): Promise<void> { export async function playMedia(filePath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(filePath);
try { try {
const response = await fetch('/api/play', { const response = await fetch('/api/play', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ file_path: filePath }), body: JSON.stringify({ file_path: normalizedPath }),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
@@ -27,7 +35,7 @@ export async function playMedia(filePath: string): Promise<void> {
} }
} catch (e) { } catch (e) {
console.error('Play media error:', 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 * Open a folder in Windows Explorer
*/ */
export async function openFolder(folderPath: string): Promise<void> { export async function openFolder(folderPath: string): Promise<void> {
const normalizedPath = normalizeMediaPath(folderPath);
try { try {
const response = await fetch('/api/open-folder', { const response = await fetch('/api/open-folder', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ folder_path: folderPath }), body: JSON.stringify({ folder_path: normalizedPath }),
}); });
if (!response.ok) { if (!response.ok) {
const error = await response.json(); const error = await response.json();
@@ -47,7 +56,33 @@ export async function openFolder(folderPath: string): Promise<void> {
} }
} catch (e) { } catch (e) {
console.error('Open folder error:', 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 {};
} }
} }
+14 -5
View File
@@ -65,7 +65,7 @@
</div> </div>
<p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p> <p v-if="getOverview(item)" class="collage-overview">{{ getOverview(item) }}</p>
<div class="collage-buttons"> <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> <button class="btn btn-secondary" @click.stop="$emit('info', item)"> Info</button>
</div> </div>
</div> </div>
@@ -375,8 +375,9 @@ function handleKeyDown(e: KeyboardEvent) {
if (!direction) { if (!direction) {
if (e.key === 'Enter' && focusedIndex.value !== null) { if (e.key === 'Enter' && focusedIndex.value !== null) {
const item = collageItems.value[focusedIndex.value]; const item = collageItems.value[focusedIndex.value];
if (item) handleItemClick(item, focusedIndex.value);
e.preventDefault(); e.preventDefault();
e.stopPropagation();
if (item) handleItemClick(item, focusedIndex.value);
} }
return; return;
} }
@@ -400,10 +401,13 @@ function handleKeyDown(e: KeyboardEvent) {
// Get attributes for an item - only item 0 participates in global nav as entry point // Get attributes for an item - only item 0 participates in global nav as entry point
function getItemAttrs(index: number) { function getItemAttrs(index: number) {
if (index === 0) { if (index === 0) {
// Big image is the entry point at row 0, col 0 in global nav // The hero always enters through the featured item when moving into row 0.
return { ...navAttrs(0, 0) }; return { ...navAttrs(0, index, 0) };
} }
return { tabindex: 0 };
// All tiles participate in the global focus model so only one visual highlight exists
// and Enter/gamepad A targets the currently highlighted tile.
return { ...navAttrs(0, index) };
} }
// Check if an item index should be visible based on its column and row // Check if an item index should be visible based on its column and row
@@ -416,6 +420,7 @@ function isItemVisible(index: number): boolean {
const props = defineProps<{ const props = defineProps<{
items: MediaItem[]; items: MediaItem[];
featuredItem?: MediaItem | null; featuredItem?: MediaItem | null;
hasResumePosition: (filePath: string | null) => boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -530,6 +535,10 @@ function handlePlay(item: MediaItem) {
if (file) emit('play', file); if (file) emit('play', file);
} }
function getPlayLabel(item: MediaItem): string {
return props.hasResumePosition(getPlayableFile(item)) ? 'Continue' : 'Play';
}
function handleItemClick(item: MediaItem, index: number) { function handleItemClick(item: MediaItem, index: number) {
if (index === 0) { if (index === 0) {
emit('info', item); emit('info', item);
+25
View File
@@ -57,6 +57,11 @@
/> />
</div> </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"> <div v-if="isDesktopApp" class="header-settings">
<button <button
class="header-settings-btn" class="header-settings-btn"
@@ -81,6 +86,7 @@ import { pickFolderAndRestart } from '../api';
const props = defineProps<{ const props = defineProps<{
currentView: 'movies' | 'series'; currentView: 'movies' | 'series';
searchQuery: string; searchQuery: string;
mpcBeConnected: boolean;
navRow: number; navRow: number;
position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero'; position: 'top' | 'after-hero' | 'after-movie-header' | 'after-series-hero';
}>(); }>();
@@ -180,3 +186,22 @@ onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown); window.removeEventListener('keydown', handleKeydown);
}); });
</script> </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'" v-if="item.type === 'series'"
:series="item.data as Series" :series="item.data as Series"
:focus-episode="focusEpisode" :focus-episode="focusEpisode"
:has-resume-position="hasResumePosition"
@close="$emit('close')" @close="$emit('close')"
@play="handlePlay" @play="handlePlay"
@openFolder="handleOpenFolder" @openFolder="handleOpenFolder"
@@ -95,7 +96,7 @@
v-bind="navAttrs(2, index * 2)" v-bind="navAttrs(2, index * 2)"
@click="handlePlay(version.playable_file)" @click="handlePlay(version.playable_file)"
:disabled="!version.playable_file" :disabled="!version.playable_file"
> Play</button> > {{ getPlayLabel(version.playable_file) }}</button>
<button <button
class="btn btn-small btn-secondary" class="btn btn-small btn-secondary"
v-bind="navAttrs(2, index * 2 + 1)" v-bind="navAttrs(2, index * 2 + 1)"
@@ -148,6 +149,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{ const props = defineProps<{
item: MediaItem; item: MediaItem;
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
hasResumePosition: (filePath: string | null) => boolean;
}>(); }>();
const emit = defineEmits<{ 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) { function handleOpenFolder(folderPath: string) {
emit('openFolder', folderPath); emit('openFolder', folderPath);
} }
+5 -1
View File
@@ -1,5 +1,9 @@
<template> <template>
<div class="media-row" :class="{ 'media-row-wrap': wrap }"> <div
class="media-row"
:class="{ 'media-row-wrap': wrap }"
:data-sync-scroll-row="!wrap && rowIndex !== undefined ? 'true' : undefined"
>
<MediaCard <MediaCard
v-for="(item, index) in items" v-for="(item, index) in items"
:key="item.id" :key="item.id"
+6 -1
View File
@@ -138,7 +138,7 @@
tabindex="0" tabindex="0"
@click="handlePlayVersion(torrent.playable_file)" @click="handlePlayVersion(torrent.playable_file)"
:disabled="!torrent.playable_file" :disabled="!torrent.playable_file"
> Play</button> > {{ getPlayLabel(torrent.playable_file) }}</button>
<button <button
class="ctx-btn ctx-btn-folder" class="ctx-btn ctx-btn-folder"
tabindex="0" tabindex="0"
@@ -164,6 +164,7 @@ import { navAttrs } from '../composables/useKeyboardNavigation';
const props = defineProps<{ const props = defineProps<{
series: Series; series: Series;
focusEpisode?: { seasonNumber: number; episodeNumber: number } | null; focusEpisode?: { seasonNumber: number; episodeNumber: number } | null;
hasResumePosition: (filePath: string | null) => boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
@@ -302,6 +303,10 @@ function handlePlayVersion(filePath: string | null) {
closeContextMenu(); closeContextMenu();
} }
function getPlayLabel(filePath: string | null): string {
return props.hasResumePosition(filePath) ? 'Continue' : 'Play';
}
// Open folder for a version // Open folder for a version
function handleOpenFolder(folderPath: string) { function handleOpenFolder(folderPath: string) {
emit('openFolder', folderPath); emit('openFolder', folderPath);
@@ -54,6 +54,13 @@ function applyGamepadAction(action: GamepadAction, isPressed: boolean, now: numb
if (!canTrigger) return; if (!canTrigger) return;
gamepadLastTriggerAt[action] = now; 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]); dispatchKey(KEY_BY_ACTION[action]);
} }
@@ -19,6 +19,210 @@ const FOCUSABLE_ATTR = 'data-nav-focusable';
const ROW_ATTR = 'data-nav-row'; const ROW_ATTR = 'data-nav-row';
const COL_ATTR = 'data-nav-col'; const COL_ATTR = 'data-nav-col';
const ENTRY_COL_ATTR = 'data-nav-entry-col'; const ENTRY_COL_ATTR = 'data-nav-entry-col';
const SYNC_SCROLL_ROW_ATTR = 'data-sync-scroll-row';
const SYNC_SCROLL_FIRST_CONTENT_ROW = 2;
const SYNC_SCROLL_DEADZONE_RATIO = 0.18;
const SYNC_SCROLL_EASING_MS = 220;
let syncedRowsFrame: number | null = null;
let syncedRowsCurrentOffset = 0;
let syncedRowsTargetOffset = 0;
let lastSyncedAnchorCol: number | null = null;
let lastSyncedRowsAnimationAt: number | null = null;
function getSyncedRows(): HTMLElement[] {
return Array.from(
document.querySelectorAll<HTMLElement>(`[${SYNC_SCROLL_ROW_ATTR}="true"]`)
);
}
function getSyncedRowMetrics(rows: HTMLElement[]) {
for (const row of rows) {
const cards = Array.from(row.querySelectorAll<HTMLElement>(`.media-card[${FOCUSABLE_ATTR}]`));
if (cards.length === 0) continue;
const firstRect = cards[0].getBoundingClientRect();
const cardWidth = firstRect.width;
if (cardWidth <= 0) continue;
const rowStyle = window.getComputedStyle(row);
const paddingLeft = parseFloat(rowStyle.paddingLeft || '0');
let gap = parseFloat(rowStyle.columnGap || rowStyle.gap || '0');
if (cards.length > 1) {
const secondRect = cards[1].getBoundingClientRect();
gap = Math.max(0, secondRect.left - firstRect.left - cardWidth);
}
return {
cardWidth,
stride: cardWidth + gap,
paddingLeft,
viewportWidth: row.clientWidth,
};
}
return null;
}
function clampRowScrollOffset(row: HTMLElement, offset: number): number {
const maxOffset = Math.max(0, row.scrollWidth - row.clientWidth);
return Math.min(Math.max(offset, 0), maxOffset);
}
function applySyncedRowScroll(offset: number, rows: HTMLElement[] = getSyncedRows()) {
for (const row of rows) {
row.scrollLeft = clampRowScrollOffset(row, offset);
}
}
function resetSyncedRows(immediate: boolean = false) {
lastSyncedAnchorCol = null;
syncedRowsTargetOffset = 0;
if (immediate) {
syncedRowsCurrentOffset = 0;
applySyncedRowScroll(0);
lastSyncedRowsAnimationAt = null;
stopSyncedRowAnimation();
return;
}
if (Math.abs(syncedRowsCurrentOffset) < 0.5) {
syncedRowsCurrentOffset = 0;
applySyncedRowScroll(0);
lastSyncedRowsAnimationAt = null;
stopSyncedRowAnimation();
return;
}
if (syncedRowsFrame === null) {
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows);
}
}
function stopSyncedRowAnimation() {
if (syncedRowsFrame !== null) {
window.cancelAnimationFrame(syncedRowsFrame);
syncedRowsFrame = null;
}
lastSyncedRowsAnimationAt = null;
}
function animateSyncedRows(now: number) {
const rows = getSyncedRows();
if (rows.length === 0) {
stopSyncedRowAnimation();
return;
}
const delta = syncedRowsTargetOffset - syncedRowsCurrentOffset;
const elapsedMs = lastSyncedRowsAnimationAt === null ? 16 : Math.max(1, now - lastSyncedRowsAnimationAt);
lastSyncedRowsAnimationAt = now;
const alpha = 1 - Math.exp(-elapsedMs / SYNC_SCROLL_EASING_MS);
syncedRowsCurrentOffset += delta * alpha;
applySyncedRowScroll(syncedRowsCurrentOffset, rows);
if (Math.abs(delta) < 0.5) {
syncedRowsCurrentOffset = syncedRowsTargetOffset;
applySyncedRowScroll(syncedRowsCurrentOffset, rows);
stopSyncedRowAnimation();
return;
}
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows);
}
function updateSyncedRowTarget(anchorCol: number) {
const rows = getSyncedRows();
if (rows.length === 0) return;
const metrics = getSyncedRowMetrics(rows);
if (!metrics) return;
const currentOffset = rows[0]?.scrollLeft ?? syncedRowsCurrentOffset;
const deadzoneInset = Math.max(
metrics.paddingLeft,
(metrics.viewportWidth - metrics.cardWidth) * SYNC_SCROLL_DEADZONE_RATIO,
);
const minVisibleLeft = deadzoneInset;
const maxVisibleLeft = Math.max(
minVisibleLeft,
metrics.viewportWidth - metrics.cardWidth - deadzoneInset,
);
const itemLeft = metrics.paddingLeft + anchorCol * metrics.stride;
const viewportLeft = itemLeft - currentOffset;
lastSyncedAnchorCol = anchorCol;
if (viewportLeft < minVisibleLeft) {
syncedRowsTargetOffset = Math.max(0, itemLeft - minVisibleLeft);
} else if (viewportLeft > maxVisibleLeft) {
syncedRowsTargetOffset = Math.max(0, itemLeft - maxVisibleLeft);
} else {
syncedRowsTargetOffset = currentOffset;
}
if (syncedRowsFrame === null) {
syncedRowsCurrentOffset = currentOffset;
}
if (Math.abs(syncedRowsTargetOffset - syncedRowsCurrentOffset) < 0.5) {
syncedRowsCurrentOffset = syncedRowsTargetOffset;
applySyncedRowScroll(syncedRowsCurrentOffset, rows);
stopSyncedRowAnimation();
return;
}
if (syncedRowsFrame === null) {
syncedRowsFrame = window.requestAnimationFrame(animateSyncedRows);
}
}
function syncRowsToElement(element: HTMLElement) {
const row = parseInt(element.getAttribute(ROW_ATTR) || '0', 10);
if (row < SYNC_SCROLL_FIRST_CONTENT_ROW) {
resetSyncedRows();
return;
}
const currentCol = parseInt(element.getAttribute(COL_ATTR) || '0', 10);
const anchorCol = desiredCol.value ?? currentCol;
updateSyncedRowTarget(anchorCol);
}
function handleSyncedRowResize() {
if (lastSyncedAnchorCol === null) {
resetSyncedRows(true);
return;
}
syncedRowsCurrentOffset = getSyncedRows()[0]?.scrollLeft ?? syncedRowsCurrentOffset;
updateSyncedRowTarget(lastSyncedAnchorCol);
}
function ensureElementVisibleVertically(element: HTMLElement) {
const rootStyle = window.getComputedStyle(document.documentElement);
const headerHeight = parseFloat(rootStyle.getPropertyValue('--header-height') || '0');
const topMargin = headerHeight + 24;
const bottomMargin = 24;
const rect = element.getBoundingClientRect();
if (rect.top < topMargin) {
window.scrollBy({
top: rect.top - topMargin,
behavior: 'smooth',
});
return;
}
if (rect.bottom > window.innerHeight - bottomMargin) {
window.scrollBy({
top: rect.bottom - (window.innerHeight - bottomMargin),
behavior: 'smooth',
});
}
}
/** /**
* Get all focusable elements in the DOM, grouped by row * Get all focusable elements in the DOM, grouped by row
@@ -179,12 +383,8 @@ function focusElement(element: HTMLElement | null) {
element.classList.add('nav-focused'); element.classList.add('nav-focused');
element.focus({ preventScroll: true }); element.focus({ preventScroll: true });
// Smooth scroll for vertical (block), instant for horizontal (inline) ensureElementVisibleVertically(element);
element.scrollIntoView({ syncRowsToElement(element);
behavior: 'smooth',
block: 'nearest',
inline: 'nearest',
});
focusedElement.value = element; focusedElement.value = element;
} }
@@ -319,8 +519,11 @@ export function installKeyboardNavigation() {
if (handlersInstalled) return; if (handlersInstalled) return;
handlersInstalled = true; handlersInstalled = true;
resetSyncedRows(true);
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
document.addEventListener('keydown', handleEnterKey); document.addEventListener('keydown', handleEnterKey);
window.addEventListener('resize', handleSyncedRowResize, { passive: true });
// Handle mouse clicks to update focus state // Handle mouse clicks to update focus state
document.addEventListener('click', (event) => { document.addEventListener('click', (event) => {
@@ -342,6 +545,9 @@ export function installKeyboardNavigation() {
focusedElement.value = target; focusedElement.value = target;
target.classList.add('nav-focused'); target.classList.add('nav-focused');
desiredCol.value = null; // Reset desired col on focus change desiredCol.value = null; // Reset desired col on focus change
syncRowsToElement(target);
} else {
resetSyncedRows();
} }
}); });
} }
-1
View File
@@ -381,7 +381,6 @@ html, body {
gap: 6px; gap: 6px;
overflow-x: auto; overflow-x: auto;
overflow-y: hidden; overflow-y: hidden;
scroll-behavior: smooth;
padding-bottom: 8px; padding-bottom: 8px;
margin: 0 -2px; margin: 0 -2px;
padding: 4px 2px 8px; padding: 4px 2px 8px;
+8 -8
View File
@@ -142,19 +142,19 @@ def make_relative_path(
path: Optional[str], root: Optional[str] = None path: Optional[str], root: Optional[str] = None
) -> Optional[str]: ) -> Optional[str]:
""" """
Convert an absolute path to a path relative to the given root. Convert an absolute path to a posix-style path relative to the given root.
If root is None, returns the path unchanged. If root is None, returns the path as a posix string unchanged.
""" """
if path is None: if path is None:
return None return None
p = Path(path)
if root is None: if root is None:
return path return p.as_posix()
root_str = str(root).rstrip("/") try:
if path.startswith(root_str): return p.relative_to(root).as_posix()
rel = path[len(root_str) :] except ValueError:
return rel.lstrip("/") return p.as_posix()
return path
def sanitize_filename(name: str) -> str: def sanitize_filename(name: str) -> str:
+47
View File
@@ -7,11 +7,14 @@ pipeline with live WebSocket updates. Excluded paths are controlled by
""" """
import asyncio import asyncio
import json
import logging import logging
import mimetypes import mimetypes
import os import os
import subprocess import subprocess
import sys import sys
import urllib.error
import urllib.request
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from pathlib import Path from pathlib import Path
@@ -146,6 +149,27 @@ def normalize_path(url_path: str) -> Path:
return MEDIAROOT / clean_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 === # === API Endpoints ===
@@ -231,6 +255,12 @@ async def get_index():
return MsgspecResponse(store.get_full_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) # 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}") 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}") @app.get("/api/media/{file_path:path}")
async def serve_media_file(file_path: str): async def serve_media_file(file_path: str):
""" """
+518
View File
@@ -5,11 +5,18 @@ Or from PyInstaller: MediaHive.exe [media_folder]
""" """
import argparse import argparse
from concurrent.futures import Future, ThreadPoolExecutor
import ctypes
import html
import json
import logging import logging
import os import os
import re
import sys import sys
import threading import threading
import time import time
import urllib.error
import urllib.parse
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
@@ -24,6 +31,507 @@ BACKEND_HOST = "127.0.0.1"
BACKEND_PORT = 8420 BACKEND_PORT = 8420
BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}" BACKEND_URL = f"http://{BACKEND_HOST}:{BACKEND_PORT}"
HEALTH_TIMEOUT = 2 # seconds 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: def _setup_logging() -> Path:
@@ -207,11 +715,21 @@ def winmain() -> None:
js_api=api, js_api=api,
) )
poll_stop = threading.Event()
poll_thread: threading.Thread | None = None
def on_shown() -> None: def on_shown() -> None:
api._window = window 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()) 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 server.should_exit = True
backend_thread.join(timeout=10) backend_thread.join(timeout=10)
+3
View File
@@ -20,6 +20,9 @@ dependencies = [
[project.scripts] [project.scripts]
mediahive = "mediahive.__main__:main" mediahive = "mediahive.__main__:main"
[project.urls]
Repository = "https://git.zi.fi/LeoVasanko/mediahive"
[build-system] [build-system]
requires = ["hatchling", "hatch-vcs"] requires = ["hatchling", "hatch-vcs"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
+221
View File
@@ -0,0 +1,221 @@
"""Publish a MediaHive release to Gitea.
Usage:
uv run scripts/release.py [--draft] [--notes "Release notes"]
Reads from [project.urls] Repository in pyproject.toml.
Token: GITEA_TOKEN environment variable
Steps:
1. Find clean-versioned ZIPs in build/ and matching dist/ wheels/sdists
2. Abort if any dist files are missing for a found ZIP version
3. Create a Gitea release for each version and upload all assets
4. Remind the user to run: uv publish
"""
import argparse
import os
import re
import sys
import tomllib
from pathlib import Path
from urllib.parse import urlparse
import httpx
REPO_ROOT = Path(__file__).parent.parent
# ---------------------------------------------------------------------------
# Config / token helpers
# ---------------------------------------------------------------------------
def load_gitea_config() -> dict:
pyproject = REPO_ROOT / "pyproject.toml"
with open(pyproject, "rb") as f:
data = tomllib.load(f)
repo_url = data.get("project", {}).get("urls", {}).get("Repository")
if not repo_url:
raise RuntimeError("[project.urls] Repository missing from pyproject.toml")
parsed = urlparse(repo_url.rstrip("/"))
parts = parsed.path.lstrip("/").split("/", 1)
if len(parts) != 2:
raise RuntimeError("[project.urls] Repository must include owner and repo, e.g. https://git.example.com/owner/repo")
return {
"url": f"{parsed.scheme}://{parsed.netloc}",
"repo": f"{parts[0]}/{parts[1]}",
}
def load_token() -> str:
token = os.environ.get("GITEA_TOKEN", "").strip()
if not token:
raise RuntimeError("GITEA_TOKEN environment variable is not set")
return token
# ---------------------------------------------------------------------------
# ZIP + dist helpers
# ---------------------------------------------------------------------------
# Matches MediaHive-1.2.3-win64.zip or MediaHive-1.2.3.4-win64.zip
# Rejects dev/dirty names like MediaHive-1.2.3.dev0+gabcd-win64.zip
_CLEAN_ZIP_RE = re.compile(r"^MediaHive-(\d+(?:\.\d+)*)-win64\.zip$")
def find_releasable_zips() -> list[tuple[Path, str]]:
"""Return (path, version) pairs for clean-versioned ZIPs in build/."""
build_dir = REPO_ROOT / "build"
results = []
for p in sorted(build_dir.glob("MediaHive-*-win64.zip")):
m = _CLEAN_ZIP_RE.match(p.name)
if m:
results.append((p, m.group(1)))
return results
def find_dist_files(version: str) -> list[Path]:
"""Return wheel and sdist paths in dist/ for the given version.
Raises FileNotFoundError listing every missing file if any are absent.
"""
dist_dir = REPO_ROOT / "dist"
ver = re.escape(version)
wheel = next(
(p for p in dist_dir.glob(f"mediahive-{version}-*.whl")), None
)
sdist = next(
(p for p in dist_dir.glob(f"mediahive-{version}.*")
if p.suffix in (".gz", ".zip") and p.name != f"mediahive-{version}.zip"),
None,
)
missing = []
if wheel is None:
missing.append(f" dist/mediahive-{version}-*.whl")
if sdist is None:
missing.append(f" dist/mediahive-{version}.tar.gz (or .zip)")
if missing:
raise FileNotFoundError(
f"Missing dist files for version {version}:\n"
+ "\n".join(missing)
+ "\nRun: uv build"
)
return [wheel, sdist]
# ---------------------------------------------------------------------------
# Gitea API helpers
# ---------------------------------------------------------------------------
def gitea_headers(token: str) -> dict:
return {"Authorization": f"token {token}", "Accept": "application/json"}
def create_release(
client: httpx.Client,
base_url: str,
repo: str,
tag: str,
version: str,
notes: str,
draft: bool,
) -> int:
"""Create a Gitea release and return its id."""
url = f"{base_url}/api/v1/repos/{repo}/releases"
payload = {
"tag_name": tag,
"name": f"MediaHive {version}",
"body": notes,
"draft": draft,
"prerelease": False,
}
resp = client.post(url, json=payload)
if resp.status_code == 409:
raise RuntimeError(
f"A release for tag '{tag}' already exists on Gitea."
)
resp.raise_for_status()
release_id = resp.json()["id"]
print(f"Created release id={release_id} (draft={draft})")
return release_id
def upload_asset(
client: httpx.Client,
base_url: str,
repo: str,
release_id: int,
path: Path,
) -> str:
"""Upload a file to the release and return the download URL."""
url = f"{base_url}/api/v1/repos/{repo}/releases/{release_id}/assets"
size_mb = path.stat().st_size / (1024 * 1024)
mime = "application/zip" if path.suffix == ".zip" else "application/octet-stream"
print(f"Uploading {path.name} ({size_mb:.1f} MB) ...")
with open(path, "rb") as fh:
resp = client.post(
url,
files={"attachment": (path.name, fh, mime)},
timeout=300,
)
resp.raise_for_status()
download_url = resp.json()["browser_download_url"]
print(f" -> {download_url}")
return download_url
# ---------------------------------------------------------------------------
# Entrypoint
# ---------------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(description="Publish a MediaHive release to Gitea")
parser.add_argument("--draft", action="store_true", help="Create as a draft release")
parser.add_argument("--notes", default="", metavar="TEXT", help="Release notes body")
args = parser.parse_args()
try:
cfg = load_gitea_config()
token = load_token()
zips = find_releasable_zips()
if not zips:
raise FileNotFoundError(
"No clean-versioned ZIPs found in build/.\n"
"Run scripts/winbuild.py first."
)
# Validate all dist files exist before touching Gitea
dist_files: dict[str, list[Path]] = {}
for _, version in zips:
dist_files[version] = find_dist_files(version)
base_url = cfg["url"].rstrip("/")
repo = cfg["repo"]
with httpx.Client(headers=gitea_headers(token)) as client:
for zip_path, version in zips:
print(f"\nReleasing {version} ...")
tag = f"v{version}"
release_id = create_release(
client, base_url, repo, tag, version, args.notes, args.draft
)
for path in [zip_path, *dist_files[version]]:
upload_asset(client, base_url, repo, release_id, path)
print(f"{tag} published")
print("\nDone. To publish to PyPI, run:")
print(" uv publish")
except Exception as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
except Exception as e:
print(f"✗ Release failed: {e}", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()