add interface to decouple MPV player from PlaybackManager
This commit is contained in:
+7
-7
@@ -207,12 +207,12 @@ func (m *MPRISHandler) PlaybackStatus() (types.PlaybackStatus, error) {
|
||||
}
|
||||
|
||||
func (m *MPRISHandler) LoopStatus() (types.LoopStatus, error) {
|
||||
switch m.pm.LoopMode() {
|
||||
case LoopModeAll:
|
||||
switch m.pm.GetLoopMode() {
|
||||
case player.LoopAll:
|
||||
return types.LoopStatusPlaylist, nil
|
||||
case LoopModeOne:
|
||||
case player.LoopOne:
|
||||
return types.LoopStatusTrack, nil
|
||||
case LoopModeNone:
|
||||
case player.LoopNone:
|
||||
return types.LoopStatusNone, nil
|
||||
}
|
||||
return "", errors.New("unknown loop status")
|
||||
@@ -221,11 +221,11 @@ func (m *MPRISHandler) LoopStatus() (types.LoopStatus, error) {
|
||||
func (m *MPRISHandler) SetLoopStatus(status types.LoopStatus) error {
|
||||
switch status {
|
||||
case types.LoopStatusPlaylist:
|
||||
return m.pm.SetLoopMode(LoopModeAll)
|
||||
return m.pm.SetLoopMode(player.LoopAll)
|
||||
case types.LoopStatusTrack:
|
||||
return m.pm.SetLoopMode(LoopModeOne)
|
||||
return m.pm.SetLoopMode(player.LoopOne)
|
||||
case types.LoopStatusNone:
|
||||
return m.pm.SetLoopMode(LoopModeNone)
|
||||
return m.pm.SetLoopMode(player.LoopNone)
|
||||
}
|
||||
return errors.New("unknown loop status")
|
||||
}
|
||||
|
||||
+58
-35
@@ -2,14 +2,12 @@ package backend
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend/mediaprovider"
|
||||
"github.com/dweymouth/supersonic/backend/util"
|
||||
"github.com/dweymouth/supersonic/player"
|
||||
"github.com/dweymouth/supersonic/player/mpv"
|
||||
"github.com/dweymouth/supersonic/sharedutil"
|
||||
)
|
||||
|
||||
@@ -20,14 +18,6 @@ var (
|
||||
ReplayGainAuto = "Auto"
|
||||
)
|
||||
|
||||
type LoopMode int
|
||||
|
||||
const (
|
||||
LoopModeNone LoopMode = LoopMode(player.LoopNone)
|
||||
LoopModeAll LoopMode = LoopMode(player.LoopAll)
|
||||
LoopModeOne LoopMode = LoopMode(player.LoopOne)
|
||||
)
|
||||
|
||||
// A high-level Subsonic-aware playback backend.
|
||||
// Manages loading tracks into the Player queue,
|
||||
// sending callbacks on play time updates and track changes.
|
||||
@@ -36,7 +26,7 @@ type PlaybackManager struct {
|
||||
cancelPollPos context.CancelFunc
|
||||
pollingTick *time.Ticker
|
||||
sm *ServerManager
|
||||
player *mpv.Player
|
||||
player player.BasePlayer
|
||||
|
||||
playTimeStopwatch util.Stopwatch
|
||||
curTrackTime float64
|
||||
@@ -44,7 +34,7 @@ type PlaybackManager struct {
|
||||
callbacksDisabled bool
|
||||
|
||||
playQueue []*mediaprovider.Track
|
||||
nowPlayingIdx int64
|
||||
nowPlayingIdx int
|
||||
|
||||
// to pass to onSongChange listeners; clear once listeners have been called
|
||||
lastScrobbled *mediaprovider.Track
|
||||
@@ -55,7 +45,7 @@ type PlaybackManager struct {
|
||||
// registered callbacks
|
||||
onSongChange []func(nowPlaying, justScrobbledIfAny *mediaprovider.Track)
|
||||
onPlayTimeUpdate []func(float64, float64)
|
||||
onLoopModeChange []func(LoopMode)
|
||||
onLoopModeChange []func(player.LoopMode)
|
||||
onVolumeChange []func(int)
|
||||
onSeek []func()
|
||||
onPaused []func()
|
||||
@@ -66,7 +56,7 @@ type PlaybackManager struct {
|
||||
func NewPlaybackManager(
|
||||
ctx context.Context,
|
||||
s *ServerManager,
|
||||
p *mpv.Player,
|
||||
p player.BasePlayer,
|
||||
scrobbleCfg *ScrobbleConfig,
|
||||
transcodeCfg *TranscodingConfig,
|
||||
) *PlaybackManager {
|
||||
@@ -79,8 +69,8 @@ func NewPlaybackManager(
|
||||
scrobbleCfg: scrobbleCfg,
|
||||
transcodeCfg: transcodeCfg,
|
||||
}
|
||||
p.OnTrackChange(func(tracknum int64) {
|
||||
if tracknum >= int64(len(pm.playQueue)) {
|
||||
p.OnTrackChange(func(tracknum int) {
|
||||
if tracknum >= len(pm.playQueue) {
|
||||
return
|
||||
}
|
||||
pm.checkScrobble() // scrobble the previous song if needed
|
||||
@@ -165,7 +155,7 @@ func (p *PlaybackManager) OnPlayTimeUpdate(cb func(float64, float64)) {
|
||||
}
|
||||
|
||||
// Registers a callback that is notified whenever the loop mode changes.
|
||||
func (p *PlaybackManager) OnLoopModeChange(cb func(LoopMode)) {
|
||||
func (p *PlaybackManager) OnLoopModeChange(cb func(player.LoopMode)) {
|
||||
p.onLoopModeChange = append(p.onLoopModeChange, cb)
|
||||
}
|
||||
|
||||
@@ -223,11 +213,18 @@ func (p *PlaybackManager) LoadTracks(tracks []*mediaprovider.Track, appendToQueu
|
||||
util.ShuffleSlice(nums)
|
||||
}
|
||||
for _, i := range nums {
|
||||
url, err := p.sm.Server.GetStreamURL(tracks[i].ID, p.transcodeCfg.ForceRawFile)
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
if urlP, ok := p.player.(player.URLPlayer); ok {
|
||||
url, err := p.sm.Server.GetStreamURL(tracks[i].ID, p.transcodeCfg.ForceRawFile)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
urlP.AppendFile(url)
|
||||
} else if trP, ok := p.player.(player.TrackIDPlayer); ok {
|
||||
trP.AppendTrack(tracks[i].ID)
|
||||
} else {
|
||||
panic("unsupported player type")
|
||||
}
|
||||
p.player.AppendFile(url)
|
||||
// ensure a deep copy of the track info so that we can maintain our own state
|
||||
// (tracking play count increases, favorite, and rating) without messing up
|
||||
// other views' track models
|
||||
@@ -245,7 +242,7 @@ func (p *PlaybackManager) PlayAlbum(albumID string, firstTrack int, shuffle bool
|
||||
p.SetReplayGainMode(player.ReplayGainAlbum)
|
||||
}
|
||||
if firstTrack <= 0 {
|
||||
return p.player.PlayFromBeginning()
|
||||
return p.player.PlayTrackAt(0)
|
||||
}
|
||||
return p.player.PlayTrackAt(firstTrack)
|
||||
}
|
||||
@@ -258,7 +255,7 @@ func (p *PlaybackManager) PlayPlaylist(playlistID string, firstTrack int, shuffl
|
||||
p.SetReplayGainMode(player.ReplayGainTrack)
|
||||
}
|
||||
if firstTrack <= 0 {
|
||||
return p.player.PlayFromBeginning()
|
||||
return p.player.PlayTrackAt(0)
|
||||
}
|
||||
return p.player.PlayTrackAt(firstTrack)
|
||||
}
|
||||
@@ -276,7 +273,7 @@ func (p *PlaybackManager) PlayTrack(trackID string) error {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayFromBeginning() error {
|
||||
return p.player.PlayFromBeginning()
|
||||
return p.player.PlayTrackAt(0)
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayTrackAt(idx int) error {
|
||||
@@ -374,6 +371,12 @@ func (p *PlaybackManager) StopAndClearPlayQueue() {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) {
|
||||
rGainPlayer, ok := p.player.(player.ReplayGainPlayer)
|
||||
if !ok {
|
||||
log.Println("Error: player doesn't support ReplayGain")
|
||||
return
|
||||
}
|
||||
|
||||
p.replayGainCfg = config
|
||||
mode := player.ReplayGainNone
|
||||
switch config.Mode {
|
||||
@@ -388,7 +391,7 @@ func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) {
|
||||
if config.Mode == ReplayGainAuto {
|
||||
mode = player.ReplayGainTrack
|
||||
}
|
||||
p.player.SetReplayGainOptions(player.ReplayGainOptions{
|
||||
rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{
|
||||
Mode: mode,
|
||||
PreventClipping: config.PreventClipping,
|
||||
PreampGain: config.PreampGainDB,
|
||||
@@ -396,7 +399,12 @@ func (p *PlaybackManager) SetReplayGainOptions(config ReplayGainConfig) {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) {
|
||||
p.player.SetReplayGainOptions(player.ReplayGainOptions{
|
||||
rGainPlayer, ok := p.player.(player.ReplayGainPlayer)
|
||||
if !ok {
|
||||
log.Println("Error: player doesn't support ReplayGain")
|
||||
return
|
||||
}
|
||||
rGainPlayer.SetReplayGainOptions(player.ReplayGainOptions{
|
||||
PreventClipping: p.replayGainCfg.PreventClipping,
|
||||
PreampGain: p.replayGainCfg.PreampGainDB,
|
||||
Mode: mode,
|
||||
@@ -406,18 +414,29 @@ func (p *PlaybackManager) SetReplayGainMode(mode player.ReplayGainMode) {
|
||||
// Changes the loop mode of the player to the next one.
|
||||
// Useful for toggling UI elements, to change modes without knowing the current player mode.
|
||||
func (p *PlaybackManager) SetNextLoopMode() error {
|
||||
if err := p.player.SetNextLoopMode(); err != nil {
|
||||
return err
|
||||
var err error
|
||||
switch p.GetLoopMode() {
|
||||
case player.LoopNone:
|
||||
err = p.SetLoopMode(player.LoopAll)
|
||||
case player.LoopAll:
|
||||
err = p.SetLoopMode(player.LoopOne)
|
||||
case player.LoopOne:
|
||||
err = p.SetLoopMode(player.LoopNone)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, cb := range p.onLoopModeChange {
|
||||
cb(LoopMode(p.player.GetLoopMode()))
|
||||
cb(p.player.GetLoopMode())
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) error {
|
||||
func (p *PlaybackManager) SetLoopMode(loopMode player.LoopMode) error {
|
||||
if err := p.player.SetLoopMode(player.LoopMode(loopMode)); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -429,8 +448,8 @@ func (p *PlaybackManager) SetLoopMode(loopMode LoopMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) LoopMode() LoopMode {
|
||||
return LoopMode(p.player.GetLoopMode())
|
||||
func (p *PlaybackManager) GetLoopMode() player.LoopMode {
|
||||
return p.player.GetLoopMode()
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) PlayerStatus() player.Status {
|
||||
@@ -457,12 +476,15 @@ func (p *PlaybackManager) SeekNext() error {
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) SeekBackOrPrevious() error {
|
||||
return p.player.SeekBackOrPrevious()
|
||||
if p.player.GetStatus().TimePos > 3 {
|
||||
return p.player.SeekSeconds(0)
|
||||
}
|
||||
return p.player.SeekPrevious()
|
||||
}
|
||||
|
||||
// Seek to given absolute position in the current track by seconds.
|
||||
func (p *PlaybackManager) SeekSeconds(sec float64) error {
|
||||
return p.player.Seek(fmt.Sprintf("%0.2f", sec), mpv.SeekAbsolute)
|
||||
return p.player.SeekSeconds(sec)
|
||||
}
|
||||
|
||||
// Seek to a fractional position in the current track [0..1]
|
||||
@@ -472,7 +494,8 @@ func (p *PlaybackManager) SeekFraction(fraction float64) error {
|
||||
} else if fraction > 1 {
|
||||
fraction = 1
|
||||
}
|
||||
return p.player.Seek(fmt.Sprintf("%0.1f", fraction*100), mpv.SeekAbsolutePercent)
|
||||
target := p.curTrackTime * fraction
|
||||
return p.player.SeekSeconds(target)
|
||||
}
|
||||
|
||||
func (p *PlaybackManager) Stop() error {
|
||||
|
||||
+13
-55
@@ -15,16 +15,6 @@ import (
|
||||
// Error returned by many Player functions if called before the player has not been initialized.
|
||||
var ErrUnitialized error = errors.New("mpv player uninitialized")
|
||||
|
||||
// Argument to Seek function (SeekAbsolute, SeekRelative, SeekAbsolutePercent, SeekRelativePercent).
|
||||
type SeekMode int
|
||||
|
||||
const (
|
||||
SeekAbsolute SeekMode = iota
|
||||
SeekRelative
|
||||
SeekAbsolutePercent
|
||||
SeekRelativePercent
|
||||
)
|
||||
|
||||
// Information about a specific audio device.
|
||||
// Returned by ListAudioDevices.
|
||||
type AudioDevice struct {
|
||||
@@ -56,6 +46,8 @@ type MediaInfo struct {
|
||||
Bitrate int
|
||||
}
|
||||
|
||||
var _ player.URLPlayer = (*Player)(nil)
|
||||
|
||||
// Player encapsulates the mpv instance and provides functions
|
||||
// to control it and to check its status.
|
||||
type Player struct {
|
||||
@@ -80,7 +72,7 @@ type Player struct {
|
||||
onStopped []func()
|
||||
onPlaying []func()
|
||||
onSeek []func()
|
||||
onTrackChange []func(int64)
|
||||
onTrackChange []func(int)
|
||||
}
|
||||
|
||||
// Returns a new player.
|
||||
@@ -203,30 +195,25 @@ func (p *Player) ClearPlayQueue() error {
|
||||
|
||||
// Seeks within the currently playing track.
|
||||
// See MPV seek command documentation for more details.
|
||||
func (p *Player) Seek(target string, mode SeekMode) error {
|
||||
func (p *Player) SeekSeconds(secs float64) error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
target := fmt.Sprintf("%0.1f", secs)
|
||||
p.seeking = true
|
||||
err := p.mpv.Command([]string{"seek", target, mode.String()})
|
||||
err := p.mpv.Command([]string{"seek", target, "absolute"})
|
||||
return err
|
||||
}
|
||||
|
||||
// Seeks to the beginning of the current track if:
|
||||
// - The current track is the first track in the play queue, or
|
||||
// - The current time is more than 3 seconds past the beginning of the track.
|
||||
//
|
||||
// Else seeks to the beginning of the previous track.
|
||||
func (p *Player) SeekBackOrPrevious() error {
|
||||
// Seeks to the beginning of the previous track,
|
||||
// or if no previous track, seeks to the beginning of the current track.
|
||||
func (p *Player) SeekPrevious() error {
|
||||
if !p.initialized {
|
||||
return ErrUnitialized
|
||||
}
|
||||
|
||||
if pos, err := p.getInt64Property("time-pos"); err == nil && pos > 3 {
|
||||
return p.Seek("0", SeekAbsolutePercent)
|
||||
}
|
||||
if pos, err := p.getInt64Property("playlist-pos"); err == nil && pos == 0 {
|
||||
return p.Seek("0", SeekAbsolutePercent)
|
||||
return p.SeekSeconds(0)
|
||||
}
|
||||
return p.mpv.Command([]string{"playlist-prev"})
|
||||
}
|
||||
@@ -445,21 +432,6 @@ func (p *Player) SetLoopMode(mode player.LoopMode) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Change the loop mode of the player to the next one.
|
||||
// Useful for toggling UI elements, to change modes without knowing the current player mode.
|
||||
func (p *Player) SetNextLoopMode() error {
|
||||
switch p.loopMode {
|
||||
case player.LoopNone:
|
||||
return p.SetLoopMode(player.LoopAll)
|
||||
case player.LoopAll:
|
||||
return p.SetLoopMode(player.LoopOne)
|
||||
case player.LoopOne:
|
||||
return p.SetLoopMode(player.LoopNone)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Get the current status of the player.
|
||||
func (p *Player) GetStatus() player.Status {
|
||||
if !p.initialized {
|
||||
@@ -475,7 +447,7 @@ func (p *Player) GetStatus() player.Status {
|
||||
p.status.Duration = dur.(float64)
|
||||
}
|
||||
if playpos, err := p.getInt64Property("playlist-pos"); err == nil {
|
||||
p.status.PlaylistPos = playpos
|
||||
p.status.PlaylistPos = int(playpos)
|
||||
}
|
||||
return p.status
|
||||
}
|
||||
@@ -586,7 +558,7 @@ func (p *Player) OnSeek(cb func()) {
|
||||
// Registers a callback which is invoked when the currently playing track changes,
|
||||
// or when playback begins at any time from the Stopped state.
|
||||
// Callback is invoked with the index of the currently playing track (zero-based).
|
||||
func (p *Player) OnTrackChange(cb func(int64)) {
|
||||
func (p *Player) OnTrackChange(cb func(int)) {
|
||||
p.onTrackChange = append(p.onTrackChange, cb)
|
||||
}
|
||||
|
||||
@@ -657,7 +629,7 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
if pos, err := p.getInt64Property("playlist-pos"); err == nil {
|
||||
p.curPlaylistPos = pos
|
||||
for _, cb := range p.onTrackChange {
|
||||
cb(pos)
|
||||
cb(int(pos))
|
||||
}
|
||||
}
|
||||
case mpv.EVENT_IDLE:
|
||||
@@ -668,17 +640,3 @@ func (p *Player) eventHandler(ctx context.Context) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s SeekMode) String() string {
|
||||
switch s {
|
||||
case SeekAbsolute:
|
||||
return "absolute"
|
||||
case SeekRelative:
|
||||
return "relative"
|
||||
case SeekAbsolutePercent:
|
||||
return "absolute-percent"
|
||||
case SeekRelativePercent:
|
||||
return "relative-percent"
|
||||
}
|
||||
return "UNKNOWN_SEEK_MODE"
|
||||
}
|
||||
|
||||
+48
-1
@@ -1,5 +1,52 @@
|
||||
package player
|
||||
|
||||
type URLPlayer interface {
|
||||
BasePlayer
|
||||
AppendFile(url string) error
|
||||
PlayFile(url string) error
|
||||
}
|
||||
|
||||
type TrackIDPlayer interface {
|
||||
BasePlayer
|
||||
AppendTrack(trackID string) error
|
||||
PlayTrack(trackID string) error
|
||||
}
|
||||
|
||||
type BasePlayer interface {
|
||||
// Transport
|
||||
PlayTrackAt(idx int) error
|
||||
Continue() error
|
||||
Pause() error
|
||||
PlayPause() error
|
||||
Stop() error
|
||||
SeekPrevious() error
|
||||
SeekNext() error
|
||||
SeekSeconds(secs float64) error
|
||||
IsSeeking() bool
|
||||
|
||||
SetVolume(int) error
|
||||
GetVolume() int
|
||||
|
||||
GetStatus() Status
|
||||
|
||||
ClearPlayQueue() error
|
||||
RemoveTrackAt(idx int) error
|
||||
|
||||
SetLoopMode(LoopMode) error
|
||||
GetLoopMode() LoopMode
|
||||
|
||||
// Event API
|
||||
OnPaused(func())
|
||||
OnStopped(func())
|
||||
OnPlaying(func())
|
||||
OnSeek(func())
|
||||
OnTrackChange(func(int))
|
||||
}
|
||||
|
||||
type ReplayGainPlayer interface {
|
||||
SetReplayGainOptions(ReplayGainOptions) error
|
||||
}
|
||||
|
||||
// The playback state (Stopped, Paused, or Playing).
|
||||
type State int
|
||||
|
||||
@@ -15,7 +62,7 @@ type Status struct {
|
||||
State State
|
||||
TimePos float64
|
||||
Duration float64
|
||||
PlaylistPos int64
|
||||
PlaylistPos int
|
||||
}
|
||||
|
||||
// The playback loop mode (LoopNone, LoopAll, LoopOne).
|
||||
|
||||
+4
-4
@@ -162,13 +162,13 @@ func (m *MainWindow) SetupSystemTrayMenu(appName string, fyneApp fyne.App) {
|
||||
if desk, ok := fyneApp.(desktop.App); ok {
|
||||
menu := fyne.NewMenu(appName,
|
||||
fyne.NewMenuItem("Play/Pause", func() {
|
||||
_ = m.App.Player.PlayPause()
|
||||
_ = m.App.PlaybackManager.PlayPause()
|
||||
}),
|
||||
fyne.NewMenuItem("Previous", func() {
|
||||
_ = m.App.Player.SeekBackOrPrevious()
|
||||
_ = m.App.PlaybackManager.SeekBackOrPrevious()
|
||||
}),
|
||||
fyne.NewMenuItem("Next", func() {
|
||||
_ = m.App.Player.SeekNext()
|
||||
_ = m.App.PlaybackManager.SeekNext()
|
||||
}),
|
||||
fyne.NewMenuItemSeparator(),
|
||||
fyne.NewMenuItem("Volume +10%", func() {
|
||||
@@ -298,7 +298,7 @@ func (m *MainWindow) addShortcuts() {
|
||||
case fyne.KeyEscape:
|
||||
m.Controller.CloseEscapablePopUp()
|
||||
case fyne.KeySpace:
|
||||
m.App.Player.PlayPause()
|
||||
m.App.PlaybackManager.PlayPause()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"github.com/dweymouth/supersonic/backend"
|
||||
"github.com/dweymouth/supersonic/player"
|
||||
myTheme "github.com/dweymouth/supersonic/ui/theme"
|
||||
"github.com/dweymouth/supersonic/ui/util"
|
||||
)
|
||||
@@ -67,15 +67,15 @@ func (a *AuxControls) OnChangeLoopMode(f func()) {
|
||||
a.loop.OnTapped = f
|
||||
}
|
||||
|
||||
func (a *AuxControls) SetLoopMode(mode backend.LoopMode) {
|
||||
func (a *AuxControls) SetLoopMode(mode player.LoopMode) {
|
||||
switch mode {
|
||||
case backend.LoopModeAll:
|
||||
case player.LoopAll:
|
||||
a.loop.Importance = widget.HighImportance
|
||||
a.loop.Icon = myTheme.RepeatIcon
|
||||
case backend.LoopModeOne:
|
||||
case player.LoopOne:
|
||||
a.loop.Importance = widget.HighImportance
|
||||
a.loop.Icon = myTheme.RepeatOneIcon
|
||||
case backend.LoopModeNone:
|
||||
case player.LoopNone:
|
||||
a.loop.Importance = widget.MediumImportance
|
||||
a.loop.Icon = myTheme.RepeatIcon
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user