diff --git a/.gitignore b/.gitignore index 36507e6..12f77a5 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ CMakeCache.txt /release/ log.txt config.ini -config.ini.bak \ No newline at end of file +config.ini.bak +.gocache \ No newline at end of file diff --git a/core/events/mapping.go b/core/events/mapping.go index a042cdb..3927f40 100644 --- a/core/events/mapping.go +++ b/core/events/mapping.go @@ -8,45 +8,65 @@ import ( ) var EventsMapping = map[string]any{ - CmdLiveRoomAdd: CmdLiveRoomAddData{}, - LiveRoomProviderUpdate: LiveRoomProviderUpdateEvent{}, - CmdLiveRoomRemove: CmdLiveRoomRemoveData{}, - UpdateLiveRoomRooms: UpdateLiveRoomRoomsData{}, - UpdateLiveRoomStatus: UpdateLiveRoomStatusData{}, - CmdLiveRoomConfigChange: CmdLiveRoomConfigChangeData{}, - CmdLiveRoomOperation: CmdLiveRoomOperationData{}, - PlayerVolumeChangeCmd: PlayerVolumeChangeCmdEvent{}, - PlayerPlayCmd: PlayerPlayCmdEvent{}, - PlayerPlayErrorUpdate: PlayerPlayErrorUpdateEvent{}, - PlayerSeekCmd: PlayerSeekCmdEvent{}, - PlayerToggleCmd: PlayerToggleCmdEvent{}, - PlayerSetPauseCmd: PlayerSetPauseCmdEvent{}, - PlayerPlayNextCmd: PlayerPlayNextCmdEvent{}, - CmdGetCurrentLyric: CmdGetCurrentLyricData{}, - UpdateCurrentLyric: UpdateCurrentLyricData{}, - PlayerLyricPosUpdate: PlayerLyricPosUpdateEvent{}, - PlayerPlayingUpdate: PlayerPlayingUpdateEvent{}, - PlayerPropertyPauseUpdate: PlayerPropertyPauseUpdateEvent{}, - PlayerPropertyPercentPosUpdate: PlayerPropertyPercentPosUpdateEvent{}, - PlayerPropertyStateUpdate: PlayerPropertyStateUpdateEvent{}, - PlayerPropertyTimePosUpdate: PlayerPropertyTimePosUpdateEvent{}, - PlayerPropertyDurationUpdate: PlayerPropertyDurationUpdateEvent{}, - PlayerPropertyVolumeUpdate: PlayerPropertyVolumeUpdateEvent{}, - PlayerVideoPlayerSetWindowHandleCmd: PlayerVideoPlayerSetWindowHandleCmdEvent{}, - PlayerSetAudioDeviceCmd: PlayerSetAudioDeviceCmdEvent{}, - PlayerAudioDeviceUpdate: PlayerAudioDeviceUpdateEvent{}, - PlaylistManagerSetSystemCmd: PlaylistManagerSetSystemCmdEvent{}, - PlaylistManagerSystemUpdate: PlaylistManagerSystemUpdateEvent{}, - PlaylistManagerRefreshCurrentCmd: PlaylistManagerRefreshCurrentCmdEvent{}, - PlaylistManagerGetCurrentCmd: PlaylistManagerGetCurrentCmdEvent{}, - PlaylistManagerCurrentUpdate: PlaylistManagerCurrentUpdateEvent{}, - PlaylistManagerInfoUpdate: PlaylistManagerInfoUpdateEvent{}, - PlaylistManagerAddPlaylistCmd: PlaylistManagerAddPlaylistCmdEvent{}, - PlaylistManagerRemovePlaylistCmd: PlaylistManagerRemovePlaylistCmdEvent{}, - MediaProviderUpdate: MediaProviderUpdateEvent{}, - CmdMiaosicSearch: CmdMiaosicSearchData{}, - ReplyMiaosicSearch: ReplyMiaosicSearchData{}, - GUISetPlayerWindowOpenCmd: GUISetPlayerWindowOpenCmdEvent{}, + CmdLiveRoomAdd: CmdLiveRoomAddData{}, + LiveRoomProviderUpdate: LiveRoomProviderUpdateEvent{}, + CmdLiveRoomRemove: CmdLiveRoomRemoveData{}, + UpdateLiveRoomRooms: UpdateLiveRoomRoomsData{}, + UpdateLiveRoomStatus: UpdateLiveRoomStatusData{}, + CmdLiveRoomConfigChange: CmdLiveRoomConfigChangeData{}, + CmdLiveRoomOperation: CmdLiveRoomOperationData{}, + PlayerVolumeChangeCmd: PlayerVolumeChangeCmdEvent{}, + PlayerPlayCmd: PlayerPlayCmdEvent{}, + PlayerPlayErrorUpdate: PlayerPlayErrorUpdateEvent{}, + PlayerSeekCmd: PlayerSeekCmdEvent{}, + PlayerToggleCmd: PlayerToggleCmdEvent{}, + PlayerSetPauseCmd: PlayerSetPauseCmdEvent{}, + PlayerPlayNextCmd: PlayerPlayNextCmdEvent{}, + CmdGetCurrentLyric: CmdGetCurrentLyricData{}, + UpdateCurrentLyric: UpdateCurrentLyricData{}, + PlayerLyricPosUpdate: PlayerLyricPosUpdateEvent{}, + PlayerPlayingUpdate: PlayerPlayingUpdateEvent{}, + PlayerPropertyPauseUpdate: PlayerPropertyPauseUpdateEvent{}, + PlayerPropertyPercentPosUpdate: PlayerPropertyPercentPosUpdateEvent{}, + PlayerPropertyStateUpdate: PlayerPropertyStateUpdateEvent{}, + PlayerPropertyTimePosUpdate: PlayerPropertyTimePosUpdateEvent{}, + PlayerPropertyDurationUpdate: PlayerPropertyDurationUpdateEvent{}, + PlayerPropertyVolumeUpdate: PlayerPropertyVolumeUpdateEvent{}, + PlayerVideoPlayerSetWindowHandleCmd: PlayerVideoPlayerSetWindowHandleCmdEvent{}, + PlayerSetAudioDeviceCmd: PlayerSetAudioDeviceCmdEvent{}, + PlayerAudioDeviceUpdate: PlayerAudioDeviceUpdateEvent{}, + PlaylistManagerSetSystemCmd: PlaylistManagerSetSystemCmdEvent{}, + PlaylistManagerSystemUpdate: PlaylistManagerSystemUpdateEvent{}, + PlaylistManagerRefreshCurrentCmd: PlaylistManagerRefreshCurrentCmdEvent{}, + PlaylistManagerGetCurrentCmd: PlaylistManagerGetCurrentCmdEvent{}, + PlaylistManagerCurrentUpdate: PlaylistManagerCurrentUpdateEvent{}, + PlaylistManagerInfoUpdate: PlaylistManagerInfoUpdateEvent{}, + PlaylistManagerAddPlaylistCmd: PlaylistManagerAddPlaylistCmdEvent{}, + PlaylistManagerRemovePlaylistCmd: PlaylistManagerRemovePlaylistCmdEvent{}, + MediaProviderUpdate: MediaProviderUpdateEvent{}, + CmdMiaosicListProviders: CmdMiaosicListProvidersData{}, + ReplyMiaosicListProviders: ReplyMiaosicListProvidersData{}, + CmdMiaosicMatchMediaByProvider: CmdMiaosicMatchMediaByProviderData{}, + ReplyMiaosicMatchMediaByProvider: ReplyMiaosicMatchMediaByProviderData{}, + CmdMiaosicSearch: CmdMiaosicSearchData{}, + ReplyMiaosicSearch: ReplyMiaosicSearchData{}, + CmdMiaosicGetMediaInfo: CmdMiaosicGetMediaInfoData{}, + ReplyMiaosicGetMediaInfo: ReplyMiaosicGetMediaInfoData{}, + CmdMiaosicGetMediaUrl: CmdMiaosicGetMediaUrlData{}, + ReplyMiaosicGetMediaUrl: ReplyMiaosicGetMediaUrlData{}, + CmdMiaosicQrLogin: CmdMiaosicQrLoginData{}, + ReplyMiaosicQrLogin: ReplyMiaosicQrLoginData{}, + CmdMiaosicQrLoginVerify: CmdMiaosicQrLoginVerifyData{}, + ReplyMiaosicQrLoginVerify: ReplyMiaosicQrLoginVerifyData{}, + CmdMiaosicLogoutByProvider: CmdMiaosicLogoutByProviderData{}, + ReplyMiaosicLogoutByProvider: ReplyMiaosicLogoutByProviderData{}, + CmdMiaosicIsLoginByProvider: CmdMiaosicIsLoginByProviderData{}, + ReplyMiaosicIsLoginByProvider: ReplyMiaosicIsLoginByProviderData{}, + CmdMiaosicRestoreSessionByProvider: CmdMiaosicRestoreSessionByProviderData{}, + ReplyMiaosicRestoreSessionByProvider: ReplyMiaosicRestoreSessionByProviderData{}, + CmdMiaosicSaveSessionByProvider: CmdMiaosicSaveSessionByProviderData{}, + ReplyMiaosicSaveSessionByProvider: ReplyMiaosicSaveSessionByProviderData{}, + GUISetPlayerWindowOpenCmd: GUISetPlayerWindowOpenCmdEvent{}, } func init() { diff --git a/core/events/miaosic.go b/core/events/miaosic.go index d0037a5..613b74f 100644 --- a/core/events/miaosic.go +++ b/core/events/miaosic.go @@ -2,6 +2,35 @@ package events import "github.com/AynaLivePlayer/miaosic" +const CmdMiaosicListProviders = "cmd.miaosic.listProviders" + +type CmdMiaosicListProvidersData struct{} + +const ReplyMiaosicListProviders = "reply.miaosic.listProviders" + +type MiaosicProviderInfo struct { + Name string `json:"name"` + Loginable bool `json:"loginable"` +} + +type ReplyMiaosicListProvidersData struct { + Providers []MiaosicProviderInfo `json:"providers"` +} + +const CmdMiaosicMatchMediaByProvider = "cmd.miaosic.matchMediaByProvider" + +type CmdMiaosicMatchMediaByProviderData struct { + Provider string `json:"provider"` + Keyword string `json:"keyword"` +} + +const ReplyMiaosicMatchMediaByProvider = "reply.miaosic.matchMediaByProvider" + +type ReplyMiaosicMatchMediaByProviderData struct { + Meta miaosic.MetaData `json:"meta"` + Found bool `json:"found"` +} + const CmdMiaosicGetMediaInfo = "cmd.miaosic.getMediaInfo" type CmdMiaosicGetMediaInfoData struct { @@ -55,3 +84,54 @@ type ReplyMiaosicQrLoginVerifyData struct { Result miaosic.QrLoginResult `json:"result"` Error error `json:"error"` } + +const CmdMiaosicLogoutByProvider = "cmd.miaosic.logoutByProvider" + +type CmdMiaosicLogoutByProviderData struct { + Provider string `json:"provider"` +} + +const ReplyMiaosicLogoutByProvider = "reply.miaosic.logoutByProvider" + +type ReplyMiaosicLogoutByProviderData struct { + Error error `json:"error"` +} + +const CmdMiaosicIsLoginByProvider = "cmd.miaosic.isLoginByProvider" + +type CmdMiaosicIsLoginByProviderData struct { + Provider string `json:"provider"` +} + +const ReplyMiaosicIsLoginByProvider = "reply.miaosic.isLoginByProvider" + +type ReplyMiaosicIsLoginByProviderData struct { + IsLogin bool `json:"is_login"` + Error error `json:"error"` +} + +const CmdMiaosicRestoreSessionByProvider = "cmd.miaosic.restoreSessionByProvider" + +type CmdMiaosicRestoreSessionByProviderData struct { + Provider string `json:"provider"` + Session string `json:"session"` +} + +const ReplyMiaosicRestoreSessionByProvider = "reply.miaosic.restoreSessionByProvider" + +type ReplyMiaosicRestoreSessionByProviderData struct { + Error error `json:"error"` +} + +const CmdMiaosicSaveSessionByProvider = "cmd.miaosic.saveSessionByProvider" + +type CmdMiaosicSaveSessionByProviderData struct { + Provider string `json:"provider"` +} + +const ReplyMiaosicSaveSessionByProvider = "reply.miaosic.saveSessionByProvider" + +type ReplyMiaosicSaveSessionByProviderData struct { + Session string `json:"session"` + Error error `json:"error"` +} diff --git a/core/events/provider.go b/core/events/provider.go index 16a0c41..57b495c 100644 --- a/core/events/provider.go +++ b/core/events/provider.go @@ -3,5 +3,6 @@ package events const MediaProviderUpdate = "update.media.provider.update" type MediaProviderUpdateEvent struct { - Providers []string + Providers []string + ProviderInfos []MiaosicProviderInfo } diff --git a/core/events/search.go b/core/events/search.go index c0a9e39..201a768 100644 --- a/core/events/search.go +++ b/core/events/search.go @@ -15,4 +15,5 @@ const ReplyMiaosicSearch = "update.search_result" type ReplyMiaosicSearchData struct { Medias []model.Media + Error error } diff --git a/go.mod b/go.mod index fa01def..596486c 100644 --- a/go.mod +++ b/go.mod @@ -12,7 +12,7 @@ replace ( ) require ( - fyne.io/fyne/v2 v2.7.1 + fyne.io/fyne/v2 v2.7.2 github.com/AynaLivePlayer/liveroom-sdk v0.1.0 github.com/AynaLivePlayer/miaosic v0.2.5 github.com/adrg/libvlc-go/v3 v3.1.6 @@ -21,7 +21,8 @@ require ( github.com/aynakeya/go-mpv v0.0.8 github.com/go-gl/glfw/v3.3/glfw v0.0.0-20250301202403-da16c1255728 github.com/go-ole/go-ole v1.3.0 - github.com/go-resty/resty/v2 v2.16.5 + github.com/go-resty/resty/v2 v2.17.1 + github.com/godbus/dbus/v5 v5.1.0 github.com/gorilla/websocket v1.5.3 github.com/mattn/go-colorable v0.1.14 github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 @@ -33,13 +34,13 @@ require ( github.com/virtuald/go-paniclog v0.0.0-20190812204905-43a7fa316459 go.uber.org/zap v1.27.0 golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 - golang.org/x/sys v0.37.0 + golang.org/x/sys v0.40.0 gopkg.in/ini.v1 v1.67.0 ) require ( - fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 // indirect - github.com/AynaLivePlayer/blivedm-go v0.0.0-20250629154348-690af765bfbc // indirect + fyne.io/systray v1.12.0 // indirect + github.com/AynaLivePlayer/blivedm-go v0.0.0-20251109134927-cc4a4ca07110 // indirect github.com/BurntSushi/toml v1.5.0 // indirect github.com/PuerkitoBio/goquery v1.10.3 // indirect github.com/XiaoMengXinX/Music163Api-Go v0.1.30 // indirect @@ -59,7 +60,6 @@ require ( github.com/go-gl/gl v0.0.0-20231021071112-07e5d0ea2e71 // indirect github.com/go-text/render v0.2.0 // indirect github.com/go-text/typesetting v0.2.1 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/safejs v0.1.0 // indirect @@ -81,8 +81,8 @@ require ( github.com/yuin/goldmark v1.7.8 // indirect go.uber.org/multierr v1.11.0 // indirect golang.org/x/image v0.24.0 // indirect - golang.org/x/net v0.46.0 // indirect - golang.org/x/text v0.30.0 // indirect + golang.org/x/net v0.49.0 // indirect + golang.org/x/text v0.33.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 11d59b1..f7e8f1c 100644 --- a/go.sum +++ b/go.sum @@ -1,9 +1,9 @@ -fyne.io/fyne/v2 v2.7.1 h1:ja7rNHWWEooha4XBIZNnPP8tVFwmTfwMJdpZmLxm2Zc= -fyne.io/fyne/v2 v2.7.1/go.mod h1:xClVlrhxl7D+LT+BWYmcrW4Nf+dJTvkhnPgji7spAwE= -fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58 h1:eA5/u2XRd8OUkoMqEv3IBlFYSruNlXD8bRHDiqm0VNI= -fyne.io/systray v1.11.1-0.20250603113521-ca66a66d8b58/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= -github.com/AynaLivePlayer/blivedm-go v0.0.0-20250629154348-690af765bfbc h1:t1fMdqUjB2lR9uuGQ9yWJ7LJ3h1hXhI+LhbTpElPueI= -github.com/AynaLivePlayer/blivedm-go v0.0.0-20250629154348-690af765bfbc/go.mod h1:u+JfexgX5pYrylIuC5zP3N/Ylp47K/xvl+ntpZtosuE= +fyne.io/fyne/v2 v2.7.2 h1:XiNpWkn0PzX43ZCjbb0QYGg1RCxVbugwfVgikWZBCMw= +fyne.io/fyne/v2 v2.7.2/go.mod h1:PXbqY3mQmJV3J1NRUR2VbVgUUx3vgvhuFJxyjRK/4Ug= +fyne.io/systray v1.12.0 h1:CA1Kk0e2zwFlxtc02L3QFSiIbxJ/P0n582YrZHT7aTM= +fyne.io/systray v1.12.0/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs= +github.com/AynaLivePlayer/blivedm-go v0.0.0-20251109134927-cc4a4ca07110 h1:A6IBTHcv/Pisf65FAsM4oxN1OXpVjpIYlVXDugCIuiw= +github.com/AynaLivePlayer/blivedm-go v0.0.0-20251109134927-cc4a4ca07110/go.mod h1:CtiYF0MDMCzrPUuM96b2194ANTtRxnA/YdtJNdAdxuQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg= github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= @@ -60,8 +60,8 @@ github.com/go-musicfox/winrt-go v0.1.4 h1:xg+7VKsIozGK8S4X4zNQ/3HNhg5yHWYaTE+Zs4 github.com/go-musicfox/winrt-go v0.1.4/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= -github.com/go-resty/resty/v2 v2.16.5 h1:hBKqmWrr7uRc3euHVqmh1HTHcKn99Smr7o5spptdhTM= -github.com/go-resty/resty/v2 v2.16.5/go.mod h1:hkJtXbA2iKHzJheXYvQ8snQES5ZLGKMwQ07xAwp/fiA= +github.com/go-resty/resty/v2 v2.17.1 h1:x3aMpHK1YM9e4va/TMDRlusDDoZiQ+ViDu/WpA6xTM4= +github.com/go-resty/resty/v2 v2.17.1/go.mod h1:kCKZ3wWmwJaNc7S29BRtUhJwy7iqmn+2mLtQrOyQlVA= github.com/go-text/render v0.2.0 h1:LBYoTmp5jYiJ4NPqDc2pz17MLmA3wHw1dZSVGcOdeAc= github.com/go-text/render v0.2.0/go.mod h1:CkiqfukRGKJA5vZZISkjSYrcdtgKQWRa2HIzvwNN5SU= github.com/go-text/typesetting v0.2.1 h1:x0jMOGyO3d1qFAPI0j4GSsh7M0Q3Ypjzr4+CEVg82V8= @@ -184,8 +184,8 @@ golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= +golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -211,8 +211,8 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= -golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -231,10 +231,10 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= -golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= +golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= +golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= diff --git a/gui/component/slider.go b/gui/component/slider.go index 322e3cb..e35a73c 100644 --- a/gui/component/slider.go +++ b/gui/component/slider.go @@ -36,3 +36,10 @@ func (s *SliderPlus) Dragged(e *fyne.DragEvent) { s.Dragging = true s.Slider.Dragged(e) } + +func (s *SliderPlus) Tapped(e *fyne.PointEvent) { + s.Slider.Tapped(e) + if s.OnDragEnd != nil { + s.OnDragEnd(s.Value) + } +} diff --git a/gui/views/player/controller.go b/gui/views/player/controller.go index b3683f5..ed40022 100644 --- a/gui/views/player/controller.go +++ b/gui/views/player/controller.go @@ -35,6 +35,7 @@ type PlayControllerContainer struct { LrcWindowOpen bool CurrentTime *component.LabelFixedSize TotalTime *widget.Label + DurationSec float64 } func (p *PlayControllerContainer) SetDefaultCover() { @@ -84,19 +85,22 @@ func registerPlayControllerHandler() { PlayController.ButtonSwitch.Refresh() })) - global.EventBus.Subscribe(gctx.EventChannel, events.PlayerPropertyPercentPosUpdate, "gui.player.controller.percent_pos", func(event *eventbus.Event) { + global.EventBus.Subscribe(gctx.EventChannel, events.PlayerPropertyPercentPosUpdate, "gui.player.controller.percent_pos", gutil.ThreadSafeHandler(func(event *eventbus.Event) { if PlayController.Progress.Dragging { return } + // Keep this path async (Do, not DoAndWait) to avoid blocking eventbus handlers. + // Using ThreadSafeHandler here also avoids a second RunInFyneThread dispatch hop. PlayController.Progress.Value = event.Data.(events.PlayerPropertyPercentPosUpdateEvent).PercentPos * 10 - gutil.RunInFyneThread(PlayController.Progress.Refresh) - }) + PlayController.Progress.Refresh() + })) global.EventBus.Subscribe(gctx.EventChannel, events.PlayerPropertyStateUpdate, "gui.player.controller.idle_active", gutil.ThreadSafeHandler(func(event *eventbus.Event) { state := event.Data.(events.PlayerPropertyStateUpdateEvent).State if state == model.PlayerStateIdle || state == model.PlayerStateLoading { PlayController.Progress.Value = 0 PlayController.Progress.Max = 0 + PlayController.DurationSec = 0 //PlayController.Title.SetText("Title") //PlayController.Artist.SetText("Artist") //PlayController.Username.SetText("Username") @@ -108,6 +112,15 @@ func registerPlayControllerHandler() { PlayController.Progress.Max = 0 PlayController.Progress.OnDragEnd = func(f float64) { + if PlayController.DurationSec > 0 { + target := (f / PlayController.Progress.Max) * PlayController.DurationSec + _ = global.EventBus.PublishToChannel(gctx.EventChannel, events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: target, + Absolute: true, + }) + return + } + // Fallback for unknown duration. _ = global.EventBus.PublishToChannel(gctx.EventChannel, events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ Position: f / 10, Absolute: false, @@ -119,7 +132,8 @@ func registerPlayControllerHandler() { })) global.EventBus.Subscribe(gctx.EventChannel, events.PlayerPropertyDurationUpdate, "gui.player.controller.duration", gutil.ThreadSafeHandler(func(event *eventbus.Event) { - PlayController.TotalTime.SetText(util.FormatTime(int(event.Data.(events.PlayerPropertyDurationUpdateEvent).Duration))) + PlayController.DurationSec = event.Data.(events.PlayerPropertyDurationUpdateEvent).Duration + PlayController.TotalTime.SetText(util.FormatTime(int(PlayController.DurationSec))) })) global.EventBus.Subscribe(gctx.EventChannel, events.PlayerPropertyVolumeUpdate, "gui.player.controller.volume", gutil.ThreadSafeHandler(func(event *eventbus.Event) { @@ -138,6 +152,7 @@ func registerPlayControllerHandler() { if event.Data.(events.PlayerPlayingUpdateEvent).Removed { PlayController.Progress.Value = 0 PlayController.Progress.Max = 0 + PlayController.DurationSec = 0 PlayController.TotalTime.SetText("0:00") PlayController.CurrentTime.SetText("0:00") PlayController.Title.SetText("Title") diff --git a/gui/views/player/handler.go b/gui/views/player/handler.go index dfdb5dc..ed3e0a6 100644 --- a/gui/views/player/handler.go +++ b/gui/views/player/handler.go @@ -11,9 +11,11 @@ func registerHandlers() { global.EventBus.Subscribe(gctx.EventChannel, events.GUISetPlayerWindowOpenCmd, "gui.player.videoplayer.handleopen", func(event *eventbus.Event) { data := event.Data.(events.GUISetPlayerWindowOpenCmdEvent) if data.SetOpen { - playerWindow.Close() - } else { showPlayerWindow() + } else { + if playerWindow != nil { + playerWindow.Hide() + } } }) } diff --git a/internal/player/player.go b/internal/player/player.go index 87df63a..2c9c9c6 100644 --- a/internal/player/player.go +++ b/internal/player/player.go @@ -18,7 +18,7 @@ func SetupPlayer() { func StopPlayer() { if config.Experimental.PlayerCore == "vlc" { - //vlc.StopPlayer() + vlc.StopPlayer() } else { mpv.StopPlayer() } diff --git a/internal/player/vlc/vlc.go b/internal/player/vlc/vlc.go index 0e3a6b5..dd8e55f 100644 --- a/internal/player/vlc/vlc.go +++ b/internal/player/vlc/vlc.go @@ -7,6 +7,7 @@ import ( "AynaLivePlayer/pkg/config" "AynaLivePlayer/pkg/eventbus" "AynaLivePlayer/pkg/logger" + "errors" "fmt" "github.com/AynaLivePlayer/miaosic" "github.com/adrg/libvlc-go/v3" @@ -26,43 +27,43 @@ var lock sync.Mutex var prevPercentPos float64 = 0 var prevTimePos float64 = 0 var duration float64 = 0 +var currentState = model.PlayerStateIdle var currentMedia model.Media var currentWindowHandle uintptr var audioDevices []model.AudioDevice var currentAudioDevice string -var videoOptions = map[string][]string{ - "windows": {"--video-filter=adjust", "--directx-hwnd"}, - "darwin": {"--vout=macosx"}, - "linux": {"--vout=x11", "--x11-display=:0"}, -} - func setWindowHandle(handle uintptr) error { - return nil + if player == nil { + return errors.New("player is not initialized") + } + if handle == 0 { + return errors.New("invalid window handle 0") + } + os := runtime.GOOS switch os { case "windows": // Windows 平台使用 DirectX - player.SetHWND(uintptr(handle)) + if err := player.SetHWND(handle); err != nil { + return err + } case "darwin": // macOS 平台使用 NSView - player.SetNSObject(handle) + if err := player.SetNSObject(handle); err != nil { + return err + } case "linux": // Linux 平台使用 XWindow - player.SetXWindow(uint32(handle)) + if err := player.SetXWindow(uint32(handle)); err != nil { + return err + } default: return fmt.Errorf("unsupported platform: %s", os) } currentWindowHandle = handle - - // 如果当前有媒体在播放,需要重新加载视频输出 - if player.IsPlaying() { - player.Stop() - player.Play() - } - return nil } @@ -71,11 +72,10 @@ func SetupPlayer() { config.LoadConfig(cfg) log = global.Logger.WithPrefix("VLC Player") - opts := []string{"--no-video", "--quiet"} - //os := runtime.GOOS - //if platformOpts, ok := videoOptions[os]; ok { - // opts = append(opts, platformOpts...) - //} + opts := []string{"--quiet"} + if !cfg.DisplayMusicCover { + opts = append(opts, "--no-video") + } // 初始化libvlc if err := vlc.Init(opts...); err != nil { @@ -113,6 +113,7 @@ func StopPlayer() { log.Infof("save audio device config: %s", cfg.AudioDevice) } running = false + currentState = model.PlayerStateIdle if player != nil { err := player.Stop() if err != nil { @@ -133,9 +134,8 @@ func StopPlayer() { func registerEvents() { // 播放结束事件 _, err := eventManager.Attach(vlc.MediaPlayerEndReached, func(e vlc.Event, userData interface{}) { - _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ - State: model.PlayerStateIdle, - }) + currentState = model.PlayerStateIdle + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{State: currentState}) _ = global.EventBus.Publish(events.PlayerPlayingUpdate, events.PlayerPlayingUpdateEvent{ Media: model.Media{}, Removed: true, @@ -195,6 +195,10 @@ func registerEvents() { _, err = eventManager.Attach(vlc.MediaPlayerPlaying, func(e vlc.Event, userData interface{}) { log.Debug("VLC player playing") + currentState = currentState.NextState(model.PlayerStatePlaying) + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ + State: currentState, + }) _ = global.EventBus.Publish(events.PlayerPropertyPauseUpdate, events.PlayerPropertyPauseUpdateEvent{ Paused: false, }) @@ -203,6 +207,39 @@ func registerEvents() { log.Error("register MediaPlayerPlaying event failed: ", err) } + _, err = eventManager.Attach(vlc.MediaPlayerOpening, func(e vlc.Event, userData interface{}) { + currentState = currentState.NextState(model.PlayerStateLoading) + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ + State: currentState, + }) + }, nil) + if err != nil { + log.Error("register MediaPlayerOpening event failed: ", err) + } + + _, err = eventManager.Attach(vlc.MediaPlayerStopped, func(e vlc.Event, userData interface{}) { + currentState = model.PlayerStateIdle + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ + State: currentState, + }) + }, nil) + if err != nil { + log.Error("register MediaPlayerStopped event failed: ", err) + } + + _, err = eventManager.Attach(vlc.MediaPlayerEncounteredError, func(e vlc.Event, userData interface{}) { + currentState = model.PlayerStateIdle + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ + State: currentState, + }) + _ = global.EventBus.Publish(events.PlayerPlayErrorUpdate, events.PlayerPlayErrorUpdateEvent{ + Error: errors.New("vlc encountered playback error"), + }) + }, nil) + if err != nil { + log.Error("register MediaPlayerEncounteredError event failed: ", err) + } + _, err = eventManager.Attach(vlc.MediaPlayerAudioVolume, func(e vlc.Event, userData interface{}) { volume, _ := player.Volume() log.Debug("VLC player audio volume: ", volume) @@ -214,14 +251,32 @@ func registerEvents() { func registerCmdHandler() { global.EventBus.Subscribe("", events.PlayerPlayCmd, "player.play", func(evnt *eventbus.Event) { - lock.Lock() - defer lock.Unlock() - mediaInfo := evnt.Data.(events.PlayerPlayCmdEvent).Media.Info + mediaData := evnt.Data.(events.PlayerPlayCmdEvent).Media + currentState = currentState.NextState(model.PlayerStateLoading) + _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ + State: currentState, + }) + log.Infof("[VLC Player] Play media %s", mediaInfo.Title) - mediaUrls, err := miaosic.GetMediaUrl(mediaInfo.Meta, miaosic.QualityAny) - if err != nil || len(mediaUrls) == 0 { + respInfo, err := global.EventBus.Call(events.CmdMiaosicGetMediaInfo, events.ReplyMiaosicGetMediaInfo, + events.CmdMiaosicGetMediaInfoData{Meta: mediaData.Info.Meta}) + if err == nil { + infoReply := respInfo.Data.(events.ReplyMiaosicGetMediaInfoData) + if infoReply.Error == nil { + mediaData.Info = infoReply.Info + } + } + + _ = global.EventBus.Publish(events.PlayerPlayingUpdate, events.PlayerPlayingUpdateEvent{ + Media: mediaData, + Removed: false, + }) + + respURL, err := global.EventBus.Call(events.CmdMiaosicGetMediaUrl, events.ReplyMiaosicGetMediaUrl, + events.CmdMiaosicGetMediaUrlData{Meta: mediaData.Info.Meta, Quality: miaosic.QualityAny}) + if err != nil { log.Warn("[VLC PlayControl] get media url failed ", mediaInfo.Meta.ID(), err) _ = global.EventBus.Publish( events.PlayerPlayErrorUpdate, @@ -230,49 +285,64 @@ func registerCmdHandler() { }) return } - - // 创建媒体对象 - var media *vlc.Media - log.Debugf("[VLC PlayControl] get player media %s", mediaUrls[0].Url) - if strings.HasPrefix(mediaUrls[0].Url, "http") { - media, err = vlc.NewMediaFromURL(mediaUrls[0].Url) - } else { - media, err = vlc.NewMediaFromPath(mediaUrls[0].Url) - } - if err != nil { - log.Error("create media failed: ", err) + mediaUrls := respURL.Data.(events.ReplyMiaosicGetMediaUrlData) + if mediaUrls.Error != nil || len(mediaUrls.Urls) == 0 { + replyErr := mediaUrls.Error + if replyErr == nil { + replyErr = errors.New("empty media url list") + } + log.Warn("[VLC PlayControl] get media url failed ", mediaInfo.Meta.ID(), replyErr) + _ = global.EventBus.Publish( + events.PlayerPlayErrorUpdate, + events.PlayerPlayErrorUpdateEvent{ + Error: replyErr, + }) return } + lock.Lock() + defer lock.Unlock() + + // 创建媒体对象 + var media *vlc.Media + mediaURL := mediaUrls.Urls[0] + log.Debugf("[VLC PlayControl] get player media %s", mediaURL.Url) + if strings.HasPrefix(mediaURL.Url, "http") { + media, err = vlc.NewMediaFromURL(mediaURL.Url) + } else { + media, err = vlc.NewMediaFromPath(mediaURL.Url) + } + if err != nil { + log.Error("create media failed: ", err) + _ = global.EventBus.Publish(events.PlayerPlayErrorUpdate, events.PlayerPlayErrorUpdateEvent{Error: err}) + return + } + defer func() { + if err := media.Release(); err != nil { + log.Warn("release media failed: ", err) + } + }() + // 设置HTTP头 - if val, ok := mediaUrls[0].Header["User-Agent"]; ok { + if val, ok := mediaURL.Header["User-Agent"]; ok { err = media.AddOptions(":http-user-agent=" + val) if err != nil { log.Warn("add http-user-agent options failed: ", err) } } - if val, ok := mediaUrls[0].Header["Referer"]; ok { + if val, ok := mediaURL.Header["Referer"]; ok { err = media.AddOptions(":http-referrer=" + val) if err != nil { log.Warn("add http-referrer options failed: ", err) } } - // 更新媒体信息 - mediaData := evnt.Data.(events.PlayerPlayCmdEvent).Media - if m, err := miaosic.GetMediaInfo(mediaData.Info.Meta); err == nil { - mediaData.Info = m - } currentMedia = mediaData - _ = global.EventBus.Publish(events.PlayerPlayingUpdate, events.PlayerPlayingUpdateEvent{ - Media: mediaData, - Removed: false, - }) - // 播放 if err := player.SetMedia(media); err != nil { log.Error("set media failed: ", err) + _ = global.EventBus.Publish(events.PlayerPlayErrorUpdate, events.PlayerPlayErrorUpdateEvent{Error: err}) return } @@ -284,6 +354,7 @@ func registerCmdHandler() { if err := player.Play(); err != nil { log.Error("play failed: ", err) + _ = global.EventBus.Publish(events.PlayerPlayErrorUpdate, events.PlayerPlayErrorUpdateEvent{Error: err}) return } @@ -296,9 +367,6 @@ func registerCmdHandler() { _ = global.EventBus.Publish(events.PlayerPropertyPercentPosUpdate, events.PlayerPropertyPercentPosUpdateEvent{ PercentPos: 0, }) - _ = global.EventBus.Publish(events.PlayerPropertyStateUpdate, events.PlayerPropertyStateUpdateEvent{ - State: model.PlayerStatePlaying, - }) }) global.EventBus.Subscribe("", events.PlayerToggleCmd, "player.toggle", func(evnt *eventbus.Event) { @@ -326,10 +394,14 @@ func registerCmdHandler() { lock.Lock() defer lock.Unlock() data := evnt.Data.(events.PlayerSeekCmdEvent) + var err error if data.Absolute { - player.SetMediaTime(int(data.Position * 1000)) // 转换为毫秒 + err = player.SetMediaTime(int(data.Position * 1000)) // 转换为毫秒 } else { - player.SetMediaPosition(float32(data.Position / 100)) + err = player.SetMediaPosition(float32(data.Position / 100)) + } + if err != nil { + log.Warn("seek failed", err) } }) @@ -345,7 +417,9 @@ func registerCmdHandler() { global.EventBus.Subscribe("", events.PlayerVideoPlayerSetWindowHandleCmd, "player.set_window_handle", func(evnt *eventbus.Event) { handle := evnt.Data.(events.PlayerVideoPlayerSetWindowHandleCmdEvent).Handle - setWindowHandle(handle) + if err := setWindowHandle(handle); err != nil { + log.Warn("set window handle failed", err) + } }) global.EventBus.Subscribe("", events.PlayerSetAudioDeviceCmd, "player.set_audio_device", func(evnt *eventbus.Event) { @@ -366,6 +440,10 @@ func setAudioDevice(deviceID string) error { lock.Lock() defer lock.Unlock() + if deviceID == "" { + return nil + } + log.Infof("set audio device to: %s", deviceID) // 验证设备是否在列表中 diff --git a/internal/source/base.go b/internal/source/base.go index cc013c6..6f470db 100644 --- a/internal/source/base.go +++ b/internal/source/base.go @@ -36,6 +36,7 @@ func Initialize() { log = global.Logger.WithPrefix("MediaProvider") loadMediaProvider() + handleProvider() handleSearch() handleInfo() createLyricLoader() @@ -43,6 +44,7 @@ func Initialize() { _ = global.EventBus.Publish( events.MediaProviderUpdate, events.MediaProviderUpdateEvent{ - Providers: miaosic.ListAvailableProviders(), + Providers: miaosic.ListAvailableProviders(), + ProviderInfos: listProviderInfos(), }) } diff --git a/internal/source/login.go b/internal/source/login.go index d140272..5e546fa 100644 --- a/internal/source/login.go +++ b/internal/source/login.go @@ -12,28 +12,8 @@ func handleSourceLogin() { events.CmdMiaosicQrLogin, "internal.media_provider.qrlogin_handler", func(event *eventbus.Event) { data := event.Data.(events.CmdMiaosicQrLoginData) log.Infof("trying login %s", data.Provider) - pvdr, ok := miaosic.GetProvider(data.Provider) - if !ok { - _ = global.EventBus.Reply( - event, events.ReplyMiaosicQrLogin, - events.ReplyMiaosicQrLoginData{ - Session: miaosic.QrLoginSession{}, - Error: miaosic.ErrorNoSuchProvider, - }) - return - } - result, ok := pvdr.(miaosic.Loginable) - if !ok { - _ = global.EventBus.Reply( - event, events.ReplyMiaosicQrLogin, - events.ReplyMiaosicQrLoginData{ - Session: miaosic.QrLoginSession{}, - Error: miaosic.ErrNotImplemented, - }) - return - } var session miaosic.QrLoginSession - sess, err := result.QrLogin() + sess, err := miaosic.QrLoginByProvider(data.Provider) if err == nil && sess != nil { session = *sess } @@ -51,28 +31,8 @@ func handleSourceLogin() { events.CmdMiaosicQrLoginVerify, "internal.media_provider.qrloginverify_handler", func(event *eventbus.Event) { data := event.Data.(events.CmdMiaosicQrLoginVerifyData) log.Infof("trying login verify %s", data.Provider) - pvdr, ok := miaosic.GetProvider(data.Provider) - if !ok { - _ = global.EventBus.Reply( - event, events.ReplyMiaosicQrLoginVerify, - events.ReplyMiaosicQrLoginVerifyData{ - Result: miaosic.QrLoginResult{}, - Error: miaosic.ErrorNoSuchProvider, - }) - return - } - loginable, ok := pvdr.(miaosic.Loginable) - if !ok { - _ = global.EventBus.Reply( - event, events.ReplyMiaosicQrLoginVerify, - events.ReplyMiaosicQrLoginVerifyData{ - Result: miaosic.QrLoginResult{}, - Error: miaosic.ErrNotImplemented, - }) - return - } var result miaosic.QrLoginResult - res, err := loginable.QrLoginVerify(&data.Session) + res, err := miaosic.QrLoginVerifyByProvider(data.Provider, &data.Session) if err == nil && res != nil { result = *res } @@ -86,4 +46,60 @@ func handleSourceLogin() { if err != nil { log.ErrorW("Subscribe miaosic qrloginverify failed", "error", err) } + + err = global.EventBus.Subscribe("", + events.CmdMiaosicLogoutByProvider, "internal.media_provider.logout_by_provider", func(event *eventbus.Event) { + data := event.Data.(events.CmdMiaosicLogoutByProviderData) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicLogoutByProvider, + events.ReplyMiaosicLogoutByProviderData{ + Error: miaosic.LogoutByProvider(data.Provider), + }) + }) + if err != nil { + log.ErrorW("Subscribe miaosic logout failed", "error", err) + } + + err = global.EventBus.Subscribe("", + events.CmdMiaosicIsLoginByProvider, "internal.media_provider.is_login_by_provider", func(event *eventbus.Event) { + data := event.Data.(events.CmdMiaosicIsLoginByProviderData) + isLogin, loginErr := miaosic.IsLoginByProvider(data.Provider) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicIsLoginByProvider, + events.ReplyMiaosicIsLoginByProviderData{ + IsLogin: isLogin, + Error: loginErr, + }) + }) + if err != nil { + log.ErrorW("Subscribe miaosic is login failed", "error", err) + } + + err = global.EventBus.Subscribe("", + events.CmdMiaosicRestoreSessionByProvider, "internal.media_provider.restore_session_by_provider", func(event *eventbus.Event) { + data := event.Data.(events.CmdMiaosicRestoreSessionByProviderData) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicRestoreSessionByProvider, + events.ReplyMiaosicRestoreSessionByProviderData{ + Error: miaosic.RestoreSessionByProvider(data.Provider, data.Session), + }) + }) + if err != nil { + log.ErrorW("Subscribe miaosic restore session failed", "error", err) + } + + err = global.EventBus.Subscribe("", + events.CmdMiaosicSaveSessionByProvider, "internal.media_provider.save_session_by_provider", func(event *eventbus.Event) { + data := event.Data.(events.CmdMiaosicSaveSessionByProviderData) + session, sessionErr := miaosic.SaveSessionByProvider(data.Provider) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicSaveSessionByProvider, + events.ReplyMiaosicSaveSessionByProviderData{ + Session: session, + Error: sessionErr, + }) + }) + if err != nil { + log.ErrorW("Subscribe miaosic save session failed", "error", err) + } } diff --git a/internal/source/provider.go b/internal/source/provider.go new file mode 100644 index 0000000..9bc2cac --- /dev/null +++ b/internal/source/provider.go @@ -0,0 +1,54 @@ +package source + +import ( + "AynaLivePlayer/core/events" + "AynaLivePlayer/global" + "AynaLivePlayer/pkg/eventbus" + "github.com/AynaLivePlayer/miaosic" +) + +func listProviderInfos() []events.MiaosicProviderInfo { + providers := make([]events.MiaosicProviderInfo, 0) + for _, providerName := range miaosic.ListAvailableProviders() { + p, ok := miaosic.GetProvider(providerName) + if !ok { + continue + } + _, loginable := p.(miaosic.Loginable) + providers = append(providers, events.MiaosicProviderInfo{ + Name: providerName, + Loginable: loginable, + }) + } + return providers +} + +func handleProvider() { + err := global.EventBus.Subscribe("", + events.CmdMiaosicListProviders, "internal.media_provider.list_providers", func(event *eventbus.Event) { + providers := listProviderInfos() + _ = global.EventBus.Reply( + event, events.ReplyMiaosicListProviders, + events.ReplyMiaosicListProvidersData{ + Providers: providers, + }) + }) + if err != nil { + log.ErrorW("Subscribe list providers event failed", "error", err) + } + + err = global.EventBus.Subscribe("", + events.CmdMiaosicMatchMediaByProvider, "internal.media_provider.match_media_by_provider", func(event *eventbus.Event) { + data := event.Data.(events.CmdMiaosicMatchMediaByProviderData) + meta, found := miaosic.MatchMediaByProvider(data.Provider, data.Keyword) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicMatchMediaByProvider, + events.ReplyMiaosicMatchMediaByProviderData{ + Meta: meta, + Found: found, + }) + }) + if err != nil { + log.ErrorW("Subscribe match media by provider event failed", "error", err) + } +} diff --git a/internal/source/search.go b/internal/source/search.go index 0409588..af83737 100644 --- a/internal/source/search.go +++ b/internal/source/search.go @@ -16,6 +16,12 @@ func handleSearch() { searchResult, err := miaosic.SearchByProvider(data.Provider, data.Keyword, 1, 10) if err != nil { log.Warnf("Search %s using %s failed: %s", data.Keyword, data.Provider, err) + _ = global.EventBus.Reply( + event, events.ReplyMiaosicSearch, + events.ReplyMiaosicSearchData{ + Medias: make([]model.Media, 0), + Error: err, + }) return } medias := make([]model.Media, len(searchResult)) @@ -29,6 +35,7 @@ func handleSearch() { event, events.ReplyMiaosicSearch, events.ReplyMiaosicSearchData{ Medias: medias, + Error: nil, }) }) if err != nil { diff --git a/internal/sysmediacontrol/smc_linux.go b/internal/sysmediacontrol/smc_linux.go index 7cbfcd5..2e806e8 100644 --- a/internal/sysmediacontrol/smc_linux.go +++ b/internal/sysmediacontrol/smc_linux.go @@ -1,9 +1,420 @@ package sysmediacontrol +import ( + "AynaLivePlayer/core/events" + "AynaLivePlayer/core/model" + "AynaLivePlayer/global" + "AynaLivePlayer/pkg/config" + "AynaLivePlayer/pkg/eventbus" + "AynaLivePlayer/pkg/logger" + "fmt" + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" + "github.com/godbus/dbus/v5/prop" + "sync" + "time" +) + +const ( + mprisBusName = "org.mpris.MediaPlayer2.AynaLivePlayer" + mprisObjPath = dbus.ObjectPath("/org/mpris/MediaPlayer2") + mprisRootIF = "org.mpris.MediaPlayer2" + mprisPlayerIF = "org.mpris.MediaPlayer2.Player" + noTrackPath = dbus.ObjectPath("/org/mpris/MediaPlayer2/TrackList/NoTrack") +) + +var ( + linuxSMCLog logger.ILogger + linuxSMC *linuxMpris +) + +type linuxMpris struct { + conn *dbus.Conn + props *prop.Properties + mu sync.Mutex + + trackSeq uint64 + trackPath dbus.ObjectPath + metadata map[string]dbus.Variant + positionUS int64 + durationUS int64 + volume float64 + playback string +} + +type mprisRoot struct{} +type mprisPlayer struct{} + +func (m *linuxMpris) nextTrackPath() dbus.ObjectPath { + m.trackSeq++ + return dbus.ObjectPath(fmt.Sprintf("/org/mpris/MediaPlayer2/track/%d", m.trackSeq)) +} + +func (m *linuxMpris) emitProps(changed map[string]dbus.Variant) { + _ = m.conn.Emit( + mprisObjPath, + "org.freedesktop.DBus.Properties.PropertiesChanged", + mprisPlayerIF, + changed, + []string{}, + ) +} + +func toMprisVolume(v float64) float64 { + if v < 0 { + v = 0 + } + return v / 100.0 +} + +func fromMprisVolume(v float64) float64 { + if v < 0 { + return 0 + } + return v * 100.0 +} + +func (m *linuxMpris) setPlayback(status string) { + m.mu.Lock() + m.playback = status + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", status) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(status), + }) +} + +func (m *linuxMpris) setPosition(seconds float64) { + pos := int64(seconds * float64(time.Second/time.Microsecond)) + if pos < 0 { + pos = 0 + } + m.mu.Lock() + m.positionUS = pos + props := m.props + m.mu.Unlock() + // Keep Position up-to-date for Get(Position), but avoid emitting change signal. + // KDE/GNOME estimate progress locally while playing; frequent signal pushes cause jitter. + if props != nil { + props.SetMust(mprisPlayerIF, "Position", pos) + } +} + +func (m *linuxMpris) setDuration(seconds float64) { + duration := int64(seconds * float64(time.Second/time.Microsecond)) + if duration < 0 { + duration = 0 + } + m.mu.Lock() + m.durationUS = duration + m.metadata["mpris:length"] = dbus.MakeVariant(duration) + md := m.metadata + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "Metadata", md) + } + m.emitProps(map[string]dbus.Variant{ + "Metadata": dbus.MakeVariant(md), + }) +} + +func (m *linuxMpris) setVolume(v float64) { + mv := toMprisVolume(v) + m.mu.Lock() + m.volume = mv + props := m.props + m.mu.Unlock() + if props != nil { + props.SetMust(mprisPlayerIF, "Volume", mv) + } + m.emitProps(map[string]dbus.Variant{ + "Volume": dbus.MakeVariant(mv), + }) +} + +func (m *linuxMpris) setPlaying(data events.PlayerPlayingUpdateEvent) { + m.mu.Lock() + defer m.mu.Unlock() + + if data.Removed { + m.trackPath = noTrackPath + m.metadata = map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(noTrackPath), + } + m.positionUS = 0 + m.playback = "Stopped" + props := m.props + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", m.playback) + props.SetMust(mprisPlayerIF, "Metadata", m.metadata) + props.SetMust(mprisPlayerIF, "Position", m.positionUS) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(m.playback), + "Metadata": dbus.MakeVariant(m.metadata), + }) + return + } + + m.trackPath = m.nextTrackPath() + metadata := map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(m.trackPath), + "xesam:title": dbus.MakeVariant(data.Media.Info.Title), + "xesam:album": dbus.MakeVariant(data.Media.Info.Album), + } + if data.Media.Info.Artist != "" { + metadata["xesam:artist"] = dbus.MakeVariant([]string{data.Media.Info.Artist}) + } + if data.Media.Info.Cover.Url != "" { + metadata["mpris:artUrl"] = dbus.MakeVariant(data.Media.Info.Cover.Url) + } + if m.durationUS > 0 { + metadata["mpris:length"] = dbus.MakeVariant(m.durationUS) + } + + m.metadata = metadata + m.positionUS = 0 + m.playback = "Playing" + props := m.props + if props != nil { + props.SetMust(mprisPlayerIF, "PlaybackStatus", m.playback) + props.SetMust(mprisPlayerIF, "Metadata", m.metadata) + props.SetMust(mprisPlayerIF, "Position", m.positionUS) + } + m.emitProps(map[string]dbus.Variant{ + "PlaybackStatus": dbus.MakeVariant(m.playback), + "Metadata": dbus.MakeVariant(m.metadata), + }) +} + +func (m *mprisRoot) Raise() *dbus.Error { return nil } +func (m *mprisRoot) Quit() *dbus.Error { return nil } + +func (m *mprisPlayer) Next() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerPlayNextCmd, events.PlayerPlayNextCmdEvent{}) + return nil +} + +func (m *mprisPlayer) Previous() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: 0, + Absolute: true, + }) + return nil +} + +func (m *mprisPlayer) Pause() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: true}) + return nil +} + +func (m *mprisPlayer) PlayPause() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerToggleCmd, events.PlayerToggleCmdEvent{}) + return nil +} + +func (m *mprisPlayer) Stop() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: true}) + return nil +} + +func (m *mprisPlayer) Play() *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSetPauseCmd, events.PlayerSetPauseCmdEvent{Pause: false}) + return nil +} + +func (m *mprisPlayer) Seek(offset int64) *dbus.Error { + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: float64(offset) / float64(time.Second/time.Microsecond), + Absolute: false, + }) + return nil +} + +func (m *mprisPlayer) SetPosition(trackID dbus.ObjectPath, position int64) *dbus.Error { + if linuxSMC == nil { + return nil + } + linuxSMC.mu.Lock() + currentTrack := linuxSMC.trackPath + linuxSMC.mu.Unlock() + if currentTrack != noTrackPath && trackID != currentTrack { + return nil + } + _ = global.EventBus.Publish(events.PlayerSeekCmd, events.PlayerSeekCmdEvent{ + Position: float64(position) / float64(time.Second/time.Microsecond), + Absolute: true, + }) + return nil +} + +func (m *mprisPlayer) OpenUri(_ string) *dbus.Error { return nil } + func InitSystemMediaControl() { - // stub + linuxSMCLog = global.Logger.WithPrefix("SMTC-Linux") + conn, err := dbus.ConnectSessionBus() + if err != nil { + linuxSMCLog.Warnf("failed to connect session bus: %v", err) + return + } + + reply, err := conn.RequestName(mprisBusName, dbus.NameFlagDoNotQueue) + if err != nil || reply != dbus.RequestNameReplyPrimaryOwner { + linuxSMCLog.Warnf("failed to own mpris bus name (%v, %v)", reply, err) + _ = conn.Close() + return + } + + linuxSMC = &linuxMpris{ + conn: conn, + trackPath: noTrackPath, + metadata: map[string]dbus.Variant{ + "mpris:trackid": dbus.MakeVariant(noTrackPath), + }, + positionUS: 0, + durationUS: 0, + volume: 0.5, + playback: "Stopped", + } + + propsSpec := map[string]map[string]*prop.Prop{ + mprisRootIF: { + "CanQuit": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "CanRaise": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "HasTrackList": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "Identity": {Value: config.ProgramName, Writable: false, Emit: prop.EmitTrue}, + "DesktopEntry": {Value: "AynaLivePlayer", Writable: false, Emit: prop.EmitTrue}, + "SupportedUriSchemes": {Value: []string{"file", "http", "https"}, Writable: false, Emit: prop.EmitTrue}, + "SupportedMimeTypes": {Value: []string{}, Writable: false, Emit: prop.EmitTrue}, + "Fullscreen": {Value: false, Writable: false, Emit: prop.EmitTrue}, + "CanSetFullscreen": {Value: false, Writable: false, Emit: prop.EmitTrue}, + }, + mprisPlayerIF: { + "PlaybackStatus": {Value: linuxSMC.playback, Writable: false, Emit: prop.EmitFalse}, + "Metadata": {Value: linuxSMC.metadata, Writable: false, Emit: prop.EmitFalse}, + "Volume": { + Value: linuxSMC.volume, + Writable: true, + Emit: prop.EmitFalse, + Callback: func(c *prop.Change) *dbus.Error { + v, ok := c.Value.(float64) + if !ok { + return dbus.MakeFailedError(fmt.Errorf("invalid volume type %T", c.Value)) + } + linuxSMC.setVolume(fromMprisVolume(v)) + _ = global.EventBus.Publish(events.PlayerVolumeChangeCmd, events.PlayerVolumeChangeCmdEvent{ + Volume: fromMprisVolume(v), + }) + return nil + }, + }, + "Position": {Value: linuxSMC.positionUS, Writable: false, Emit: prop.EmitFalse}, + "CanGoNext": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanGoPrevious": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanPlay": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanPause": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanSeek": {Value: true, Writable: false, Emit: prop.EmitTrue}, + "CanControl": {Value: true, Writable: false, Emit: prop.EmitTrue}, + }, + } + + linuxSMC.props = prop.New(conn, mprisObjPath, propsSpec) + _ = conn.Export(&mprisRoot{}, mprisObjPath, mprisRootIF) + _ = conn.Export(&mprisPlayer{}, mprisObjPath, mprisPlayerIF) + + node := &introspect.Node{ + Name: string(mprisObjPath), + Interfaces: []introspect.Interface{ + introspect.IntrospectData, + prop.IntrospectData, + { + Name: mprisRootIF, + Methods: []introspect.Method{ + {Name: "Raise"}, + {Name: "Quit"}, + }, + }, + { + Name: mprisPlayerIF, + Methods: []introspect.Method{ + {Name: "Next"}, + {Name: "Previous"}, + {Name: "Pause"}, + {Name: "PlayPause"}, + {Name: "Stop"}, + {Name: "Play"}, + { + Name: "Seek", + Args: []introspect.Arg{{Name: "Offset", Type: "x", Direction: "in"}}, + }, + { + Name: "SetPosition", + Args: []introspect.Arg{ + {Name: "TrackId", Type: "o", Direction: "in"}, + {Name: "Position", Type: "x", Direction: "in"}, + }, + }, + { + Name: "OpenUri", + Args: []introspect.Arg{{Name: "Uri", Type: "s", Direction: "in"}}, + }, + }, + Signals: []introspect.Signal{ + { + Name: "Seeked", + Args: []introspect.Arg{{Name: "Position", Type: "x"}}, + }, + }, + }, + }, + } + _ = conn.Export(introspect.NewIntrospectable(node), mprisObjPath, "org.freedesktop.DBus.Introspectable") + + _ = global.EventBus.Subscribe("", events.PlayerPlayingUpdate, "sysmediacontrol.linux.playing", func(event *eventbus.Event) { + linuxSMC.setPlaying(event.Data.(events.PlayerPlayingUpdateEvent)) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyPauseUpdate, "sysmediacontrol.linux.pause", func(event *eventbus.Event) { + if event.Data.(events.PlayerPropertyPauseUpdateEvent).Paused { + linuxSMC.setPlayback("Paused") + } else { + linuxSMC.setPlayback("Playing") + } + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyDurationUpdate, "sysmediacontrol.linux.duration", func(event *eventbus.Event) { + linuxSMC.setDuration(event.Data.(events.PlayerPropertyDurationUpdateEvent).Duration) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyTimePosUpdate, "sysmediacontrol.linux.timepos", func(event *eventbus.Event) { + linuxSMC.setPosition(event.Data.(events.PlayerPropertyTimePosUpdateEvent).TimePos) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyVolumeUpdate, "sysmediacontrol.linux.volume", func(event *eventbus.Event) { + linuxSMC.setVolume(event.Data.(events.PlayerPropertyVolumeUpdateEvent).Volume) + }) + _ = global.EventBus.Subscribe("", events.PlayerPropertyStateUpdate, "sysmediacontrol.linux.state", func(event *eventbus.Event) { + state := event.Data.(events.PlayerPropertyStateUpdateEvent).State + if state == model.PlayerStateIdle { + linuxSMC.setPlayback("Stopped") + } + }) + + linuxSMCLog.Info("linux MPRIS media control initialized") } func Destroy() { - // stub + if linuxSMC == nil { + return + } + _ = global.EventBus.Unsubscribe(events.PlayerPlayingUpdate, "sysmediacontrol.linux.playing") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyPauseUpdate, "sysmediacontrol.linux.pause") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyDurationUpdate, "sysmediacontrol.linux.duration") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyTimePosUpdate, "sysmediacontrol.linux.timepos") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyVolumeUpdate, "sysmediacontrol.linux.volume") + _ = global.EventBus.Unsubscribe(events.PlayerPropertyStateUpdate, "sysmediacontrol.linux.state") + + _, _ = linuxSMC.conn.ReleaseName(mprisBusName) + _ = linuxSMC.conn.Close() + linuxSMC = nil } diff --git a/internal/sysmediacontrol/smc_linux_test.go b/internal/sysmediacontrol/smc_linux_test.go new file mode 100644 index 0000000..0d8f7bb --- /dev/null +++ b/internal/sysmediacontrol/smc_linux_test.go @@ -0,0 +1,153 @@ +//go:build linux + +package sysmediacontrol + +import ( + "AynaLivePlayer/core/events" + "AynaLivePlayer/global" + "AynaLivePlayer/pkg/eventbus" + "errors" + "sync" + "testing" + + "github.com/godbus/dbus/v5" + "github.com/stretchr/testify/require" +) + +type publishRecord struct { + id string + data interface{} +} + +type mockBus struct { + mu sync.Mutex + published []publishRecord +} + +func (m *mockBus) Start() error { return nil } +func (m *mockBus) Wait() error { return nil } +func (m *mockBus) Stop() error { return nil } +func (m *mockBus) Subscribe(string, string, string, eventbus.HandlerFunc) error { + return nil +} +func (m *mockBus) SubscribeAny(string, string, eventbus.HandlerFunc) error { return nil } +func (m *mockBus) SubscribeOnce(string, string, string, eventbus.HandlerFunc) error { + return nil +} +func (m *mockBus) Unsubscribe(string, string) error { return nil } +func (m *mockBus) Publish(eventID string, data interface{}) error { + m.mu.Lock() + defer m.mu.Unlock() + m.published = append(m.published, publishRecord{id: eventID, data: data}) + return nil +} +func (m *mockBus) PublishToChannel(string, string, interface{}) error { return nil } +func (m *mockBus) PublishEvent(*eventbus.Event) error { return nil } +func (m *mockBus) Call(string, string, interface{}) (*eventbus.Event, error) { + return nil, errors.New("not implemented") +} +func (m *mockBus) Reply(*eventbus.Event, string, interface{}) error { return nil } + +func (m *mockBus) snapshot() []publishRecord { + m.mu.Lock() + defer m.mu.Unlock() + out := make([]publishRecord, len(m.published)) + copy(out, m.published) + return out +} + +func setupSMCTest(t *testing.T) *mockBus { + t.Helper() + oldBus := global.EventBus + oldSMC := linuxSMC + mb := &mockBus{} + global.EventBus = mb + linuxSMC = nil + t.Cleanup(func() { + global.EventBus = oldBus + linuxSMC = oldSMC + }) + return mb +} + +func TestMprisPlayerPublishesControlEvents(t *testing.T) { + mb := setupSMCTest(t) + p := &mprisPlayer{} + + require.Nil(t, p.Next()) + require.Nil(t, p.Previous()) + require.Nil(t, p.Pause()) + require.Nil(t, p.Play()) + require.Nil(t, p.PlayPause()) + require.Nil(t, p.Stop()) + require.Nil(t, p.Seek(3_000_000)) + + pubs := mb.snapshot() + require.Len(t, pubs, 7) + + require.Equal(t, events.PlayerPlayNextCmd, pubs[0].id) + _, ok := pubs[0].data.(events.PlayerPlayNextCmdEvent) + require.True(t, ok) + + require.Equal(t, events.PlayerSeekCmd, pubs[1].id) + prevSeek, ok := pubs[1].data.(events.PlayerSeekCmdEvent) + require.True(t, ok) + require.True(t, prevSeek.Absolute) + require.Equal(t, 0.0, prevSeek.Position) + + require.Equal(t, events.PlayerSetPauseCmd, pubs[2].id) + pauseEvt, ok := pubs[2].data.(events.PlayerSetPauseCmdEvent) + require.True(t, ok) + require.True(t, pauseEvt.Pause) + + require.Equal(t, events.PlayerSetPauseCmd, pubs[3].id) + playEvt, ok := pubs[3].data.(events.PlayerSetPauseCmdEvent) + require.True(t, ok) + require.False(t, playEvt.Pause) + + require.Equal(t, events.PlayerToggleCmd, pubs[4].id) + _, ok = pubs[4].data.(events.PlayerToggleCmdEvent) + require.True(t, ok) + + require.Equal(t, events.PlayerSetPauseCmd, pubs[5].id) + stopEvt, ok := pubs[5].data.(events.PlayerSetPauseCmdEvent) + require.True(t, ok) + require.True(t, stopEvt.Pause) + + require.Equal(t, events.PlayerSeekCmd, pubs[6].id) + seekEvt, ok := pubs[6].data.(events.PlayerSeekCmdEvent) + require.True(t, ok) + require.False(t, seekEvt.Absolute) + require.InDelta(t, 3.0, seekEvt.Position, 1e-6) +} + +func TestMprisPlayerSetPositionTrackGuard(t *testing.T) { + mb := setupSMCTest(t) + p := &mprisPlayer{} + + linuxSMC = &linuxMpris{ + trackPath: dbus.ObjectPath("/org/mpris/MediaPlayer2/track/1"), + } + + require.Nil(t, p.SetPosition(dbus.ObjectPath("/org/mpris/MediaPlayer2/track/2"), 5_000_000)) + require.Len(t, mb.snapshot(), 0) + + require.Nil(t, p.SetPosition(dbus.ObjectPath("/org/mpris/MediaPlayer2/track/1"), 5_000_000)) + pubs := mb.snapshot() + require.Len(t, pubs, 1) + require.Equal(t, events.PlayerSeekCmd, pubs[0].id) + seekEvt, ok := pubs[0].data.(events.PlayerSeekCmdEvent) + require.True(t, ok) + require.True(t, seekEvt.Absolute) + require.InDelta(t, 5.0, seekEvt.Position, 1e-6) + + linuxSMC.trackPath = noTrackPath + require.Nil(t, p.SetPosition(dbus.ObjectPath("/org/mpris/MediaPlayer2/track/whatever"), 2_000_000)) + pubs = mb.snapshot() + require.Len(t, pubs, 2) + lastEvt, ok := pubs[1].data.(events.PlayerSeekCmdEvent) + require.True(t, ok) + require.True(t, lastEvt.Absolute) + require.InDelta(t, 2.0, lastEvt.Position, 1e-6) +} + diff --git a/pkg/config/config.go b/pkg/config/config.go index 4d6baeb..eca6c2b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -10,7 +10,7 @@ import ( const ( ProgramName = "卡西米尔唱片机" - Version uint32 = 0x010202 + Version uint32 = 0x010203 ) const ( diff --git a/pkg/eventbus/bus_impl.go b/pkg/eventbus/bus_impl.go index 0b4db15..fe6bf6e 100644 --- a/pkg/eventbus/bus_impl.go +++ b/pkg/eventbus/bus_impl.go @@ -3,6 +3,9 @@ package eventbus import ( "errors" "fmt" + "runtime" + "strconv" + "strings" "sync" "sync/atomic" "time" @@ -51,6 +54,10 @@ type bus struct { // logger log Logger + + // worker context markers for deadlock detection in Call() + workerCtxMu sync.RWMutex + workerCtx map[uint64]int // goroutine id -> worker idx } // New creates a new Bus. @@ -75,22 +82,23 @@ func New(opts ...Option) Bus { pending: make([]*Event, 0, 16), echoWaiter: make(map[string]chan *Event), log: option.log, + workerCtx: make(map[uint64]int), } for i := 0; i < option.maxWorkerSize; i++ { - b.addWorker() + b.addWorker(i) } return b } -func (b *bus) addWorker() { +func (b *bus) addWorker(workerIdx int) { b.mu.Lock() q := make(chan task, b.queueSize) b.queues = append(b.queues, q) - go b.workerLoop(q) + go b.workerLoop(workerIdx, q) b.mu.Unlock() } -func (b *bus) workerLoop(q chan task) { +func (b *bus) workerLoop(workerIdx int, q chan task) { for { select { case <-b.stopCh: @@ -105,7 +113,14 @@ func (b *bus) workerLoop(q chan task) { } case t := <-q: func() { + gid := curGID() + b.workerCtxMu.Lock() + b.workerCtx[gid] = workerIdx + b.workerCtxMu.Unlock() defer func() { + b.workerCtxMu.Lock() + delete(b.workerCtx, gid) + b.workerCtxMu.Unlock() if r := recover(); r != nil { b.log.Printf("handler panic recovered: event=%s handler=%s panic=%v", t.ev.Id, t.h.name, r) } @@ -301,6 +316,9 @@ func (b *bus) Call(eventId string, subEvtId string, data interface{}) (*Event, e if eventId == "" { return nil, errors.New("empty eventId") } + if b.willDeadlockOnCall(eventId) { + return nil, fmt.Errorf("potential deadlock detected: sync Call(%s) from same worker shard", eventId) + } echo := b.nextEchoId() wait := make(chan *Event, 1) @@ -328,6 +346,40 @@ func (b *bus) Call(eventId string, subEvtId string, data interface{}) (*Event, e } } +func (b *bus) willDeadlockOnCall(eventId string) bool { + gid := curGID() + b.workerCtxMu.RLock() + currentWorker, inWorker := b.workerCtx[gid] + b.workerCtxMu.RUnlock() + if !inWorker { + return false + } + + b.mu.RLock() + targetWorker, hasWorker := b.workerIdxes[eventId] + b.mu.RUnlock() + if !hasWorker { + return false + } + return currentWorker == targetWorker +} + +func curGID() uint64 { + var buf [64]byte + n := runtime.Stack(buf[:], false) + // first line format: "goroutine 123 [running]:\n" + line := strings.TrimPrefix(string(buf[:n]), "goroutine ") + space := strings.IndexByte(line, ' ') + if space <= 0 { + return 0 + } + id, err := strconv.ParseUint(line[:space], 10, 64) + if err != nil { + return 0 + } + return id +} + func (b *bus) Reply(req *Event, eventId string, data interface{}) error { return b.PublishEvent(&Event{ Id: eventId, diff --git a/pkg/eventbus/bus_impl_test.go b/pkg/eventbus/bus_impl_test.go index cf18053..e494bc7 100644 --- a/pkg/eventbus/bus_impl_test.go +++ b/pkg/eventbus/bus_impl_test.go @@ -243,6 +243,37 @@ func TestCall(t *testing.T) { require.Equal(t, "response to my-data", resp.Data) } +func TestCall_FastFailOnPotentialDeadlock(t *testing.T) { + b := New(WithMaxWorkerSize(1), WithQueueSize(10)) + err := b.Start() + require.NoError(t, err) + defer b.Stop() + + // Ensure target event has a worker shard assignment. + err = b.Subscribe("", "inner-request", "inner-responder", func(event *Event) { + _ = b.Reply(event, "inner-response", "ok") + }) + require.NoError(t, err) + + done := make(chan error, 1) + err = b.Subscribe("", "outer-request", "outer-handler", func(event *Event) { + _, callErr := b.Call("inner-request", "inner-response", nil) + done <- callErr + }) + require.NoError(t, err) + + err = b.Publish("outer-request", nil) + require.NoError(t, err) + + select { + case callErr := <-done: + require.Error(t, callErr) + require.Contains(t, callErr.Error(), "potential deadlock detected") + case <-time.After(2 * time.Second): + t.Fatal("outer handler did not finish in time") + } +} + // TestCall_StopDuringWait checks that Call returns an error if the bus is stopped while waiting. func TestCall_StopDuringWait(t *testing.T) { b := New(WithMaxWorkerSize(1), WithQueueSize(10)) diff --git a/pkg/liveroom-sdk b/pkg/liveroom-sdk index 7e498e6..6db40cc 160000 --- a/pkg/liveroom-sdk +++ b/pkg/liveroom-sdk @@ -1 +1 @@ -Subproject commit 7e498e64e593b828507df4e65132f55cf96ebd7c +Subproject commit 6db40cc03804ccda6a352198eefa61a4cdb4dda7 diff --git a/pkg/miaosic b/pkg/miaosic index 2e615a1..f8fee56 160000 --- a/pkg/miaosic +++ b/pkg/miaosic @@ -1 +1 @@ -Subproject commit 2e615a15d711ddb58303a507bffec5c7cb7e7228 +Subproject commit f8fee5639380b2024c9682932c4814779a445174 diff --git a/plugin/diange/diange.go b/plugin/diange/diange.go index 63b5ba2..0d219ec 100644 --- a/plugin/diange/diange.go +++ b/plugin/diange/diange.go @@ -168,20 +168,24 @@ func (d *Diange) Enable() error { } d.isCurrentSystem = (!data.Media.IsLiveRoomUser()) && (data.Media.ToUser().Name == model.SystemUser.Name) }) - // check if all available source has a command in sourceConfigs, if not add this source to sourceConfigs - // actually, if default config exists, then this code does nothing. - prvdrs := miaosic.ListAvailableProviders() - for _, pvdr := range prvdrs { - // found pvdr in command list - if _, ok := d.sourceConfigs[pvdr]; ok { - continue - } - d.sourceConfigs[pvdr] = &sourceConfig{ - Enable: true, - Command: "点" + pvdr + "歌", - Priority: len(d.sourceConfigs) + 1, - } - } + global.EventBus.Subscribe("", + events.MediaProviderUpdate, + "plugin.diange.provider.update", + func(event *eventbus.Event) { + // check if all available source has a command in sourceConfigs, if not add this source to sourceConfigs + // actually, if default config exists, then this code does nothing. + data := event.Data.(events.MediaProviderUpdateEvent) + for _, pvdr := range data.Providers { + if _, ok := d.sourceConfigs[pvdr]; ok { + continue + } + d.sourceConfigs[pvdr] = &sourceConfig{ + Enable: true, + Command: "点" + pvdr + "歌", + Priority: len(d.sourceConfigs) + 1, + } + } + }) return nil } @@ -216,6 +220,22 @@ func (d *Diange) getSource(cmd string) []string { return sources } +func (d *Diange) searchByProvider(provider, keywords string) ([]model.Media, error) { + resp, err := global.EventBus.Call( + events.CmdMiaosicSearch, + events.ReplyMiaosicSearch, + events.CmdMiaosicSearchData{ + Keyword: keywords, + Provider: provider, + }, + ) + if err != nil { + return nil, err + } + reply := resp.Data.(events.ReplyMiaosicSearchData) + return reply.Medias, reply.Error +} + func (d *Diange) handleMessage(event *eventbus.Event) { message := event.Data.(events.LiveRoomMessageReceiveEvent).Message msgs := strings.Split(message.Message, " ") @@ -292,7 +312,20 @@ func (d *Diange) handleMessage(event *eventbus.Event) { var mediaMeta miaosic.MetaData found := false for _, source := range sources { - mediaMeta, found = miaosic.MatchMediaByProvider(source, keywords) + resp, err := global.EventBus.Call( + events.CmdMiaosicMatchMediaByProvider, + events.ReplyMiaosicMatchMediaByProvider, + events.CmdMiaosicMatchMediaByProviderData{ + Provider: source, + Keyword: keywords, + }, + ) + if err != nil { + continue + } + match := resp.Data.(events.ReplyMiaosicMatchMediaByProviderData) + mediaMeta = match.Meta + found = match.Found if found { break } @@ -302,22 +335,34 @@ func (d *Diange) handleMessage(event *eventbus.Event) { if !found { for _, source := range sources { - medias, err := miaosic.SearchByProvider(source, keywords, 1, 10) + medias, err := d.searchByProvider(source, keywords) if len(medias) == 0 || err != nil { continue } - media = medias[0] + media = medias[0].Info found = true break } } else { d.log.Info("Match media: ", mediaMeta) - m, err := miaosic.GetMediaInfo(mediaMeta) + resp, err := global.EventBus.Call( + events.CmdMiaosicGetMediaInfo, + events.ReplyMiaosicGetMediaInfo, + events.CmdMiaosicGetMediaInfoData{ + Meta: mediaMeta, + }, + ) if err != nil { d.log.Error("Get media info failed: ", err) found = false + } else { + reply := resp.Data.(events.ReplyMiaosicGetMediaInfoData) + if reply.Error != nil { + d.log.Error("Get media info failed: ", reply.Error) + found = false + } + media = reply.Info } - media = m } if found { @@ -410,11 +455,7 @@ func (d *Diange) CreatePanel() fyne.CanvasObject { skipPlaylistCheck, ) sourceCfgs := []fyne.CanvasObject{} - prvdrs := miaosic.ListAvailableProviders() for source, cfg := range d.sourceConfigs { - if !slices.Contains(prvdrs, source) { - continue - } sourceCfgs = append( sourceCfgs, container.NewGridWithColumns(2, widget.NewLabel(source), diff --git a/plugin/sourcelogin/sourcelogin.go b/plugin/sourcelogin/sourcelogin.go index e104f0f..a4a5f80 100644 --- a/plugin/sourcelogin/sourcelogin.go +++ b/plugin/sourcelogin/sourcelogin.go @@ -6,6 +6,7 @@ import ( "AynaLivePlayer/gui/component" config2 "AynaLivePlayer/gui/views/config" "AynaLivePlayer/pkg/config" + "AynaLivePlayer/pkg/eventbus" "AynaLivePlayer/pkg/i18n" "AynaLivePlayer/pkg/logger" "AynaLivePlayer/resource" @@ -55,15 +56,14 @@ func (w *SourceLogin) Enable() error { func (w *SourceLogin) Disable() error { w.log.Info("save session for all provider") - providers := miaosic.ListAvailableProviders() - for _, pname := range providers { - if p, ok := miaosic.GetProvider(pname); ok { - pl, ok2 := p.(miaosic.Loginable) - if ok2 { - w.log.Infof("save session for %s", pname) - w.sessions[pname] = pl.SaveSession() - } + for _, provider := range w.listLoginableProviders() { + w.log.Infof("save session for %s", provider) + session, err := w.saveSession(provider) + if err != nil { + w.log.Warnf("save session for %s failed: %v", provider, err) + continue } + w.sessions[provider] = session } return nil } @@ -86,30 +86,16 @@ func (w *SourceLogin) CreatePanel() fyne.CanvasObject { widget.NewLabel(i18n.T("plugin.sourcelogin.current_user")), currentUser) - providers := miaosic.ListAvailableProviders() - loginableProviders := make([]string, 0) - loginables := make(map[string]miaosic.MediaProvider) - for _, pname := range providers { - if p, ok := miaosic.GetProvider(pname); ok { - pl, ok2 := p.(miaosic.Loginable) - if ok2 { - loginableProviders = append(loginableProviders, pname) - loginables[pname] = p - if session, ok3 := w.sessions[pname]; ok3 { - err := pl.RestoreSession(session) - if err != nil { - w.log.Error("failed to restore session for ", pname) - } - } - } - } - } - providerChoice := widget.NewSelect(loginableProviders, func(s string) { + providerChoice := widget.NewSelect([]string{}, func(s string) { w.log.Info("switching provider to ", s) if s != "" { - pvdr, _ := miaosic.GetProvider(s) - provider := pvdr.(miaosic.Loginable) - if provider.IsLogin() { + isLogin, err := w.isLogin(s) + if err != nil { + _ = global.EventBus.Publish(events.ErrorUpdate, + events.ErrorUpdateEvent{Error: err}) + return + } + if isLogin { currentUser.SetText(i18n.T("plugin.sourcelogin.current_user.loggedin")) } else { currentUser.SetText(i18n.T("plugin.sourcelogin.current_user.notlogin")) @@ -119,11 +105,49 @@ func (w *SourceLogin) CreatePanel() fyne.CanvasObject { sourcePanel := container.NewGridWithColumns(2, providerChoice, currentStatus) + restoredSessions := make(map[string]bool) + _ = global.EventBus.Subscribe("", + events.MediaProviderUpdate, + "plugin.sourcelogin.providers", + func(event *eventbus.Event) { + data := event.Data.(events.MediaProviderUpdateEvent) + loginableProviders := make([]string, 0) + for _, providerInfo := range data.ProviderInfos { + if providerInfo.Loginable { + loginableProviders = append(loginableProviders, providerInfo.Name) + } + } + for _, provider := range loginableProviders { + if restoredSessions[provider] { + continue + } + session, ok := w.sessions[provider] + if !ok || session == "" { + continue + } + restoredSessions[provider] = true + go func(providerName string, providerSession string) { + if err := w.restoreSession(providerName, providerSession); err != nil { + w.log.Warnf("failed to restore session for %s: %v", providerName, err) + } + }(provider, session) + } + fyne.DoAndWait(func() { + providerChoice.Options = loginableProviders + providerChoice.Refresh() + if providerChoice.Selected == "" && len(loginableProviders) > 0 { + providerChoice.SetSelected(loginableProviders[0]) + } + }) + }) logoutBtn := component.NewAsyncButton( i18n.T("plugin.sourcelogin.logout"), func() { - err := loginables[providerChoice.Selected].(miaosic.Loginable).Logout() + if providerChoice.Selected == "" { + return + } + err := w.logout(providerChoice.Selected) if err != nil { _ = global.EventBus.Publish(events.ErrorUpdate, events.ErrorUpdateEvent{Error: err}) @@ -153,14 +177,25 @@ func (w *SourceLogin) CreatePanel() fyne.CanvasObject { qrStatus.SetText("") }) w.log.Info("getting a new qr code for login") - pvdr, _ := miaosic.GetProvider(providerChoice.Selected) - provider := pvdr.(miaosic.Loginable) - currentLoginSession, err = provider.QrLogin() + resp, err := global.EventBus.Call( + events.CmdMiaosicQrLogin, + events.ReplyMiaosicQrLogin, + events.CmdMiaosicQrLoginData{ + Provider: providerChoice.Selected, + }, + ) if err != nil { _ = global.EventBus.Publish(events.ErrorUpdate, events.ErrorUpdateEvent{Error: err}) return } + qrData := resp.Data.(events.ReplyMiaosicQrLoginData) + if qrData.Error != nil { + _ = global.EventBus.Publish(events.ErrorUpdate, + events.ErrorUpdateEvent{Error: qrData.Error}) + return + } + currentLoginSession = &qrData.Session w.log.Debugf("trying encode url %s to qrcode", currentLoginSession.Url) data, err := qrcode.Encode(currentLoginSession.Url, qrcode.Medium, 256) if err != nil { @@ -186,26 +221,43 @@ func (w *SourceLogin) CreatePanel() fyne.CanvasObject { if currentProvider == "" { return } - pvdr, _ := miaosic.GetProvider(currentProvider) - provider := pvdr.(miaosic.Loginable) w.log.Info("checking qr status") - result, err := provider.QrLoginVerify(currentLoginSession) + resp, err := global.EventBus.Call( + events.CmdMiaosicQrLoginVerify, + events.ReplyMiaosicQrLoginVerify, + events.CmdMiaosicQrLoginVerifyData{ + Provider: currentProvider, + Session: *currentLoginSession, + }, + ) if err != nil { _ = global.EventBus.Publish(events.ErrorUpdate, events.ErrorUpdateEvent{Error: err}) return } + resultData := resp.Data.(events.ReplyMiaosicQrLoginVerifyData) + if resultData.Error != nil { + _ = global.EventBus.Publish(events.ErrorUpdate, + events.ErrorUpdateEvent{Error: resultData.Error}) + return + } fyne.DoAndWait(func() { - qrStatus.SetText(result.Message) + qrStatus.SetText(resultData.Result.Message) }) - if result.Success { + if resultData.Result.Success { currentLoginSession = nil fyne.DoAndWait(func() { qrcodeImg.Resource = resource.ImageEmptyQrCode qrcodeImg.Refresh() providerChoice.OnChanged(currentProvider) }) - w.sessions[currentProvider] = provider.SaveSession() + session, sessionErr := w.saveSession(currentProvider) + if sessionErr != nil { + _ = global.EventBus.Publish(events.ErrorUpdate, + events.ErrorUpdateEvent{Error: sessionErr}) + return + } + w.sessions[currentProvider] = session } }, ) @@ -216,3 +268,84 @@ func (w *SourceLogin) CreatePanel() fyne.CanvasObject { w.panel = container.NewVBox(sourcePanel, controlBox, qrImagePanel) return w.panel } + +func (w *SourceLogin) listLoginableProviders() []string { + resp, err := global.EventBus.Call( + events.CmdMiaosicListProviders, + events.ReplyMiaosicListProviders, + events.CmdMiaosicListProvidersData{}, + ) + if err != nil { + w.log.Warnf("list providers failed: %v", err) + return []string{} + } + data := resp.Data.(events.ReplyMiaosicListProvidersData) + providers := make([]string, 0) + for _, provider := range data.Providers { + if provider.Loginable { + providers = append(providers, provider.Name) + } + } + return providers +} + +func (w *SourceLogin) isLogin(provider string) (bool, error) { + resp, err := global.EventBus.Call( + events.CmdMiaosicIsLoginByProvider, + events.ReplyMiaosicIsLoginByProvider, + events.CmdMiaosicIsLoginByProviderData{ + Provider: provider, + }, + ) + if err != nil { + return false, err + } + data := resp.Data.(events.ReplyMiaosicIsLoginByProviderData) + return data.IsLogin, data.Error +} + +func (w *SourceLogin) logout(provider string) error { + resp, err := global.EventBus.Call( + events.CmdMiaosicLogoutByProvider, + events.ReplyMiaosicLogoutByProvider, + events.CmdMiaosicLogoutByProviderData{ + Provider: provider, + }, + ) + if err != nil { + return err + } + data := resp.Data.(events.ReplyMiaosicLogoutByProviderData) + return data.Error +} + +func (w *SourceLogin) restoreSession(provider, session string) error { + resp, err := global.EventBus.Call( + events.CmdMiaosicRestoreSessionByProvider, + events.ReplyMiaosicRestoreSessionByProvider, + events.CmdMiaosicRestoreSessionByProviderData{ + Provider: provider, + Session: session, + }, + ) + if err != nil { + return err + } + data := resp.Data.(events.ReplyMiaosicRestoreSessionByProviderData) + return data.Error +} + +func (w *SourceLogin) saveSession(provider string) (string, error) { + resp, err := global.EventBus.Call( + events.CmdMiaosicSaveSessionByProvider, + events.ReplyMiaosicSaveSessionByProvider, + events.CmdMiaosicSaveSessionByProviderData{ + Provider: provider, + }, + ) + if err != nil { + return "", err + } + data := resp.Data.(events.ReplyMiaosicSaveSessionByProviderData) + return data.Session, data.Error +} diff --git a/plugin/wshub/utils.go b/plugin/wshub/utils.go index 5e85d5e..f75265d 100644 --- a/plugin/wshub/utils.go +++ b/plugin/wshub/utils.go @@ -3,18 +3,26 @@ package wshub import ( "encoding/json" "fmt" + "strings" "unicode" ) -// capitalize is a helper function to safely capitalize the first letter of a string. -// It's robust against empty strings. -func capitalize(s string) string { +// toCamelCase converts under_score or lowerCamel to UpperCamel (PascalCase) for JSON key compatibility. +// It is robust against empty strings. +func toCamelCase(s string) string { if s == "" { return "" } - r := []rune(s) - r[0] = unicode.ToUpper(r[0]) - return string(r) + parts := strings.Split(s, "_") + for i := range parts { + if parts[i] == "" { + continue + } + r := []rune(parts[i]) + r[0] = unicode.ToUpper(r[0]) + parts[i] = string(r) + } + return strings.Join(parts, "") } // capitalizeKeys recursively traverses an interface{} and capitalizes the keys of any maps it finds. @@ -26,7 +34,7 @@ func capitalizeKeys(data interface{}) interface{} { newMap := make(map[string]interface{}) for k, v := range value { // Capitalize the key and recursively process the value. - newMap[capitalize(k)] = capitalizeKeys(v) + newMap[toCamelCase(k)] = capitalizeKeys(v) } return newMap