Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bb46c162e | ||
|
|
81fbfee5d3 | ||
|
|
c405ea98b3 | ||
|
|
76c4cc0829 | ||
|
|
c3f5ac2f6e |
@@ -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
@@ -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
@@ -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
@@ -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 {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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):
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user