@@ -596,7 +596,10 @@ function renderChannels() {
596596
597597 const cue = document . createElement ( 'span' ) ;
598598 cue . className = 'ch-cue' ;
599- cue . textContent = 'Choose Monitor or Viewer' ;
599+ const remembered = roleFor ( ch ) ;
600+ cue . textContent = remembered === 'station' ? 'Resume as Monitor'
601+ : remembered === 'viewer' ? 'Resume as Viewer'
602+ : 'Choose Monitor or Viewer' ;
600603
601604 const fp = document . createElement ( 'span' ) ;
602605 fp . className = 'ch-fp' ;
@@ -635,6 +638,8 @@ function deleteChannel(ch) {
635638 localStorage . removeItem ( `tawny.sas.${ ch . id } ` ) ;
636639 localStorage . removeItem ( `tawny.bond.${ ch . id } ` ) ;
637640 localStorage . removeItem ( `tawny.paired.${ ch . id } ` ) ;
641+ localStorage . removeItem ( `tawny.token.${ ch . id } ` ) ;
642+ localStorage . removeItem ( `tawny.role.${ ch . id } ` ) ;
638643 } catch { }
639644 renderChannels ( ) ;
640645 toast ( 'Monitor removed' ) ;
@@ -658,6 +663,11 @@ $('#row-rename').addEventListener('click', () => {
658663 closeRowMenu ( ) ;
659664 if ( ch ) openEditor ( ch ) ;
660665} ) ;
666+ $ ( '#row-switch-role' ) ?. addEventListener ( 'click' , ( ) => {
667+ const ch = menuChannel ;
668+ closeRowMenu ( ) ;
669+ if ( ch ) openRole ( ch , true ) ;
670+ } ) ;
661671$ ( '#row-delete' ) . addEventListener ( 'click' , ( ) => {
662672 const ch = menuChannel ;
663673 closeRowMenu ( ) ;
@@ -705,16 +715,23 @@ $('#editor-save').addEventListener('click', saveEditor);
705715$ ( '#editor-cancel' ) . addEventListener ( 'click' , ( ) => { closeSheet ( el . editor ) ; S . editing = null ; } ) ;
706716el . editorName . addEventListener ( 'keydown' , ( e ) => { if ( e . key === 'Enter' ) saveEditor ( ) ; } ) ;
707717
708- function openRole ( ch ) {
718+ /**
719+ * `force` skips the remembered role — the "Switch role" row-menu action uses
720+ * it, so a household that repurposes a phone is not stuck asking Tawny to
721+ * forget first.
722+ */
723+ function openRole ( ch , force = false ) {
709724 S . channel = ch ;
725+ const remembered = ! force && roleFor ( ch ) ;
726+ if ( remembered ) { start ( remembered ) ; return ; }
710727 el . roleName . textContent = ch . name ;
711728 note ( el . roleNote , '' ) ;
712729 show ( el . role ) ;
713730}
714731
715732$ ( '#role-back' ) . addEventListener ( 'click' , ( ) => { S . channel = null ; show ( el . channels ) ; } ) ;
716- $ ( '#pick-station' ) . addEventListener ( 'click' , ( ) => start ( 'station' ) ) ;
717- $ ( '#pick-viewer' ) . addEventListener ( 'click' , ( ) => start ( 'viewer' ) ) ;
733+ $ ( '#pick-station' ) . addEventListener ( 'click' , ( ) => { setRoleFor ( S . channel , 'station' ) ; start ( 'station' ) ; } ) ;
734+ $ ( '#pick-viewer' ) . addEventListener ( 'click' , ( ) => { setRoleFor ( S . channel , 'viewer' ) ; start ( 'viewer' ) ; } ) ;
718735
719736// ------------------------------------------------------- first-run setup
720737
@@ -771,6 +788,7 @@ function tickWatchPair() {
771788
772789async function openWatchPair ( ) {
773790 S . channel = watcherChannel ( ) ;
791+ setRoleFor ( S . channel , 'station' ) ;
774792 // Same rule as openPair(): never open on a code that has already lapsed.
775793 if ( pairCodeLeft ( ) <= 0 && ! S . nativeShell ) newPairCode ( ) ;
776794 // refreshWatchPair() first, because pairLink() is what mints S.token — and
@@ -952,6 +970,40 @@ function fillRelayHint(box, code) {
952970
953971const PAIRED_KEY = ( ) => `tawny.paired.${ S . channel ?. id } ` ;
954972const BOND_KEY = ( ) => `tawny.bond.${ S . channel ?. id } ` ;
973+ // The pairing ticket itself — the shared secret a Monitor mints once and every
974+ // Viewer must present. Neither role used to keep it past the tab it was set
975+ // in: a browser Monitor minted a fresh one on every page load, and a browser
976+ // Viewer only ever had one moment (adopt()'s URL fragment) to learn it. Either
977+ // alone made hangUp()'s reload, a browser refresh, or just reopening a channel
978+ // from the list indistinguishable from the code having expired — the relay
979+ // refuses a hello with no ticket (or the wrong one) the same way it refuses a
980+ // stranger. Persisted per channel, it survives all three.
981+ const TOKEN_KEY = ( ) => `tawny.token.${ S . channel ?. id } ` ;
982+ function savedToken ( ) {
983+ let v = null ;
984+ try { v = localStorage . getItem ( TOKEN_KEY ( ) ) ; } catch { }
985+ return TICKET_RE . test ( v || '' ) ? v : null ;
986+ }
987+ function saveToken ( token ) {
988+ if ( ! S . channel || ! TICKET_RE . test ( token || '' ) ) return ;
989+ try { localStorage . setItem ( TOKEN_KEY ( ) , token ) ; } catch { }
990+ }
991+
992+ // Which job this device does for a given channel, remembered so opening it a
993+ // second time goes straight to that screen instead of asking again — a phone
994+ // left as the household's Viewer is the Viewer every time, not a question.
995+ // Explicit `ch` argument (not S.channel) because renderChannels() reads this
996+ // for every row in the list, not just the one currently open.
997+ const ROLE_KEY = ( ch ) => `tawny.role.${ ch ?. id } ` ;
998+ function roleFor ( ch ) {
999+ let v = null ;
1000+ try { v = localStorage . getItem ( ROLE_KEY ( ch ) ) ; } catch { }
1001+ return v === 'station' || v === 'viewer' ? v : null ;
1002+ }
1003+ function setRoleFor ( ch , role ) {
1004+ if ( ! ch ) return ;
1005+ try { localStorage . setItem ( ROLE_KEY ( ch ) , role ) ; } catch { }
1006+ }
9551007// One Monitor holds three phones; twice that is room for a household that has
9561008// re-installed a couple of times, and small enough that a stolen code cannot
9571009// quietly enrol an army.
@@ -1032,7 +1084,7 @@ function pairLink() {
10321084 // exist by the time the QR is drawn or the link would hand out a `t` the
10331085 // Monitor then replaced. Only the browser mints one: inside the native shell
10341086 // the ticket comes from the shell, and pairLink() is not the screen in use.
1035- if ( ! S . token && ! S . nativeShell ) S . token = newKey ( ) ;
1087+ if ( ! S . token && ! S . nativeShell ) S . token = savedToken ( ) || newKey ( ) ;
10361088 const frag = new URLSearchParams ( { k : S . channel . key , n : S . channel . name , r : 'viewer' } ) ;
10371089 if ( S . token ) frag . set ( 't' , S . token ) ;
10381090 frag . set ( 'c' , code . c ) ;
@@ -1360,6 +1412,10 @@ function adopt(raw) {
13601412 }
13611413 renderChannels ( ) ;
13621414 S . channel = ch ;
1415+ // Remember the ticket this scan just handed us — this device's only chance
1416+ // to learn it — so reconnecting later never depends on rescanning.
1417+ if ( S . token ) saveToken ( S . token ) ;
1418+ setRoleFor ( ch , 'viewer' ) ;
13631419 S . pending = 'viewer' ;
13641420 el . joinName . textContent = ch . name ;
13651421 note ( el . joinNote , lastRelayNote || '' ) ;
@@ -3677,7 +3733,13 @@ async function start(role, opts = {}) {
36773733 // could only work with ticket checking turned off. It mints its own now, and
36783734 // this must happen before fetchIce() asks /turn for credentials and before
36793735 // pairLink() builds the fragment that hands it on.
3680- if ( role === 'station' && ! S . token ) S . token = newKey ( ) ;
3736+ if ( role === 'station' && ! S . token ) S . token = savedToken ( ) || newKey ( ) ;
3737+ // A Viewer resuming this channel from the list (not a fresh scan) has no
3738+ // token in memory — it only ever lived in this session's S. Pull back
3739+ // whatever it last learned, so "click Viewer" reconnects instead of dialling
3740+ // in with nothing to present and being told the code expired.
3741+ if ( role === 'viewer' && ! S . token ) S . token = savedToken ( ) ;
3742+ if ( S . token ) saveToken ( S . token ) ;
36813743 S . relayBase = null ; // a new session gives the user's own relay a fresh try
36823744 S . roomId = await roomIdFor ( S . channel . key ) ;
36833745 diag ( `start role=${ role } room=${ S . roomId } lan=${ S . signalUrl || '-' } ` +
@@ -4234,6 +4296,19 @@ $('#btn-mute').addEventListener('click', () => {
42344296 btn . querySelector ( 'span:last-child' ) . textContent = S . micOn ? 'Mute mic' : 'Unmute mic' ;
42354297} ) ;
42364298
4299+ // Viewer: silence what plays back here. Purely local — the Monitor keeps
4300+ // transmitting and nobody else's session is touched, so muting to check a
4301+ // noisy room does not also mute talk-back or a chime landing at the far end.
4302+ $ ( '#btn-mute-viewer' ) ?. addEventListener ( 'click' , ( ) => {
4303+ const btn = $ ( '#btn-mute-viewer' ) ;
4304+ el . remoteAudio . muted = ! el . remoteAudio . muted ;
4305+ const muted = el . remoteAudio . muted ;
4306+ btn . setAttribute ( 'aria-pressed' , String ( muted ) ) ;
4307+ press ( btn , muted ) ;
4308+ btn . querySelector ( 'span:last-child' ) . textContent = muted
4309+ ? TawnyT . t ( 'w_ctl_unmute' ) : TawnyT . t ( 'w_ctl_mute_listen' ) ;
4310+ } ) ;
4311+
42374312// Dim mode. The overlay used to be only a black <div> laid over the page: on
42384313// an LCD the backlight stayed wherever the user had left it, and on an AMOLED
42394314// the panel still drove every pixel while the compositor, the preview decode
0 commit comments